1
0

Umsetzung von M1

This commit is contained in:
2026-04-20 10:11:19 +02:00
parent cd6e5221aa
commit b5044f62a9
59 changed files with 5891 additions and 884 deletions
@@ -1,164 +1,30 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.domain.model.InputFile;
import de.gecheckt.asv.adapter.out.parsing.DefaultInputFileParser;
import de.gecheckt.asv.adapter.out.parsing.DefaultSegmentLineTokenizer;
import de.gecheckt.asv.adapter.out.parsing.InputFileParseException;
import de.gecheckt.asv.adapter.out.parsing.InputFileParser;
import de.gecheckt.asv.adapter.out.parsing.SegmentLineTokenizer;
import de.gecheckt.asv.application.DefaultInputFileValidator;
import de.gecheckt.asv.application.InputFileValidator;
import de.gecheckt.asv.application.field.DefaultFieldValidator;
import de.gecheckt.asv.application.field.FieldValidator;
import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.application.structure.DefaultStructureValidator;
import de.gecheckt.asv.application.structure.StructureValidator;
import de.gecheckt.asv.adapter.out.reporting.ValidationResultPrinter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Haupteinstiegspunkt für die ASV Format Validator CLI-Anwendung.
*
* Diese Anwendung validiert Dateien gegen ein segmentorientiertes Dateiformat.
* Sie nimmt einen Dateipfad als Kommandozeilenargument entgegen, parst die Datei,
* validiert sie und gibt die Ergebnisse auf der Konsole aus.
* Ehemaliger Haupteinstiegspunkt des ASV-Format-Validators.
*
* <p><strong>Veraltet seit AP06.</strong> Die Verantwortlichkeiten wurden aufgeteilt:</p>
* <ul>
* <li>Bootstrap und Constructor Injection → {@link de.gecheckt.asv.bootstrap.Main}</li>
* <li>CLI-Argument-Verarbeitung und Exit-Code → {@link CliRunner}</li>
* </ul>
*
* <p>Diese Klasse bleibt als leere Hülle erhalten, bis AP09 (Altlogik einfrieren) abgeschlossen
* ist. Sie darf nicht mehr direkt verwendet werden.</p>
*
* @deprecated Ersetzt durch {@link de.gecheckt.asv.bootstrap.Main} und {@link CliRunner} (AP06).
* Wird in AP09 endgültig entfernt.
*/
@Deprecated(since = "AP06", forRemoval = true)
public class AsvValidatorApplication {
private static final int EXIT_CODE_SUCCESS = 0;
private static final int EXIT_CODE_INVALID_ARGUMENTS = 1;
private static final int EXIT_CODE_FILE_ERROR = 2;
private static final int EXIT_CODE_VALIDATION_ERRORS = 3;
private static final Logger logger = LoggerFactory.getLogger(AsvValidatorApplication.class);
private final InputFileParser parser;
private final InputFileValidator validator;
private final ValidationResultPrinter printer;
/**
* Konstruktor für einen AsvValidatorApplication mit Standardkomponenten.
*/
public AsvValidatorApplication() {
// Initialize all required components
SegmentLineTokenizer tokenizer = new DefaultSegmentLineTokenizer();
this.parser = new DefaultInputFileParser(tokenizer);
StructureValidator structureValidator = new DefaultStructureValidator();
FieldValidator fieldValidator = new DefaultFieldValidator();
this.validator = new DefaultInputFileValidator(structureValidator, fieldValidator);
this.printer = new ValidationResultPrinter();
}
/**
* Konstruktor für einen AsvValidatorApplication mit den bereitgestellten Komponenten.
* Dieser Konstruktor unterstützt Dependency Injection für bessere Testbarkeit.
*
* @param parser der Parser zum Parsen von Eingabedateien
* @param validator der Validator zum Validieren geparster Dateien
* @param printer der Printer zum Anzeigen von Validierungsergebnissen
*/
public AsvValidatorApplication(InputFileParser parser, InputFileValidator validator, ValidationResultPrinter printer) {
this.parser = parser;
this.validator = validator;
this.printer = printer;
}
/**
* Haupteinstiegspunkt für die Anwendung.
*
* @param args Kommandozeilenargumente - erwartet genau ein Argument: den Dateipfad
* Nicht mehr verwenden. Nur noch als Kompatibilitätshülle vorhanden.
*
* @deprecated Verwende {@link de.gecheckt.asv.bootstrap.Main#main(String[])} stattdessen.
*/
@Deprecated(since = "AP06", forRemoval = true)
public static void main(String[] args) {
AsvValidatorApplication app = new AsvValidatorApplication();
int exitCode = app.run(args);
System.exit(exitCode);
de.gecheckt.asv.bootstrap.Main.main(args);
}
/**
* Führt die Anwendung mit den bereitgestellten Argumenten aus.
*
* @param args Kommandozeilenargumente
* @return Exit-Code (0 für Erfolg, ungleich 0 für Fehler)
*/
public int run(String[] args) {
// Validate command line arguments
if (args.length != 1) {
printUsage();
return EXIT_CODE_INVALID_ARGUMENTS;
}
String filePath = args[0];
try {
// Parse the file
InputFile inputFile = parseFile(filePath);
// Validate the parsed file
ValidationResult result = validator.validate(inputFile);
// Output results
printer.printToConsole(result);
// Return appropriate exit code based on validation results
return result.hasErrors() ? EXIT_CODE_VALIDATION_ERRORS : EXIT_CODE_SUCCESS;
} catch (IOException e) {
logger.error("Fehler beim Lesen der Datei: {}", e.getMessage(), e);
System.err.println("Fehler beim Lesen der Datei: " + e.getMessage());
return EXIT_CODE_FILE_ERROR;
} catch (InputFileParseException e) {
logger.error("Fehler beim Parsen der Datei: {}", e.getMessage(), e);
System.err.println("Fehler beim Parsen der Datei: " + e.getMessage());
return EXIT_CODE_FILE_ERROR;
} catch (Exception e) {
logger.error("Unerwarteter Fehler während der Validierung: {}", e.getMessage(), e);
System.err.println("Unerwarteter Fehler während der Validierung: " + e.getMessage());
return EXIT_CODE_FILE_ERROR;
}
}
/**
* Parst eine Datei unter dem gegebenen Pfad.
*
* @param filePath Pfad zur zu parsenden Datei
* @return geparstes InputFile-Objekt
* @throws IOException wenn die Datei nicht gelesen werden kann
* @throws InputFileParseException wenn die Datei nicht geparst werden kann
*/
private InputFile parseFile(String filePath) throws IOException, InputFileParseException {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
throw new IOException("File does not exist: " + filePath);
}
if (!Files.isRegularFile(path)) {
throw new IOException("Path is not a regular file: " + filePath);
}
if (!Files.isReadable(path)) {
throw new IOException("File is not readable: " + filePath);
}
String fileContent = Files.readString(path, StandardCharsets.UTF_8);
return parser.parse(path.getFileName().toString(), fileContent);
}
/**
* Gibt Nutzungsinformationen auf der Konsole aus.
*/
private void printUsage() {
System.out.println("ASV Format Validator");
System.out.println("Verwendung: java -jar asv-format-validator.jar <datei-pfad>");
System.out.println(" <datei-pfad> Pfad zur zu validierenden Datei");
}
}
}
@@ -0,0 +1,262 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.adapter.out.logging.LoggingConfigurator;
import de.gecheckt.asv.adapter.out.reporting.ReportFileWriter;
import de.gecheckt.asv.application.FileValidationService;
import de.gecheckt.asv.domain.finding.ValidationReport;
import de.gecheckt.asv.domain.finding.Verdict;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
/**
* Eingehender CLI-Adapter. Nimmt Kommandozeilenargumente entgegen, prüft die
* Eingabedatei auf Existenz und Lesbarkeit, konfiguriert die Log-Datei, delegiert
* die Validierung an den {@link FileValidationService}, schreibt die Berichtdatei und
* gibt das Ergebnis auf der Konsole aus. Übersetzt das Ergebnis in einen numerischen
* Exit-Code gemäß {@link ExitCode}.
*
* <p>Reihenfolge pro Lauf (AP07/AP08):</p>
* <ol>
* <li>Argument-Prüfung und Datei-Vorabprüfung</li>
* <li>Bei Bedienfehler: Minimalbericht erzeugen und ggf. Berichtdatei schreiben</li>
* <li>Log-Datei-Pfad via {@link SuffixResolver} bestimmen</li>
* <li>{@link LoggingConfigurator#configureLogFile(Path)} aufrufen</li>
* <li>Validierungslauf über {@link FileValidationService#validate(Path)}</li>
* <li>Berichtdatei über {@link ReportFileWriter#write(ValidationReport, Path)} schreiben</li>
* <li>Berichtinhalt auf der Konsole ausgeben</li>
* </ol>
*
* <p>Dieser Adapter enthält keinerlei Log4j2-Typen. Logging erfolgt ausschließlich
* über die SLF4J-Fassade. Die Log4j2-Umkonfiguration delegiert er an
* {@link LoggingConfigurator}, der im {@code adapter.out.logging}-Paket liegt.</p>
*
* <p><strong>Bedienfehler-Fälle (AP08):</strong></p>
* <ul>
* <li>Kein Argument → nur Konsole (Verzeichnis unbekannt)</li>
* <li>Mehr als ein Argument → nur Konsole</li>
* <li>Eingabedatei existiert nicht → Konsole + Berichtdatei im übergeordneten Verzeichnis</li>
* <li>Pfad ist kein regulärer Dateityp → nur Konsole</li>
* <li>Datei nicht lesbar → Konsole + Berichtdatei im übergeordneten Verzeichnis</li>
* </ul>
*/
public class CliRunner {
private static final Logger log = LoggerFactory.getLogger(CliRunner.class);
/** Platzhalter-Dateiname für Fälle ohne auflösbaren Dateinamen. */
private static final String PLACEHOLDER_NO_ARG = "<kein Argument>";
private static final String PLACEHOLDER_MANY_ARGS = "<mehrere Argumente>";
private final FileValidationService validationService;
private final LoggingConfigurator loggingConfigurator;
private final SuffixResolver suffixResolver;
private final ReportFileWriter reportFileWriter;
/**
* Erzeugt einen neuen {@code CliRunner} mit allen benötigten Adaptern.
*
* @param validationService Dienst, der die Dateivalidierung übernimmt (nicht null)
* @param loggingConfigurator Konfiguriert den Log-Datei-Pfad (nicht null)
* @param suffixResolver Ermittelt den freien Log-Datei-Pfad (nicht null)
* @param reportFileWriter Schreibt die Berichtdatei (nicht null)
*/
public CliRunner(FileValidationService validationService,
LoggingConfigurator loggingConfigurator,
SuffixResolver suffixResolver,
ReportFileWriter reportFileWriter) {
this.validationService = Objects.requireNonNull(validationService,
"validationService darf nicht null sein");
this.loggingConfigurator = Objects.requireNonNull(loggingConfigurator,
"loggingConfigurator darf nicht null sein");
this.suffixResolver = Objects.requireNonNull(suffixResolver,
"suffixResolver darf nicht null sein");
this.reportFileWriter = Objects.requireNonNull(reportFileWriter,
"reportFileWriter darf nicht null sein");
}
/**
* Führt den CLI-Lauf mit den übergebenen Argumenten durch.
*
* <p>Genau ein Positionsargument wird erwartet: der Pfad zur Eingabedatei.
* Bei 0 oder ≥ 2 Argumenten wird Exit-Code {@link ExitCode#OPERATIONAL_ERROR} (2)
* zurückgegeben und ein Minimalbericht auf STDERR und (wo möglich) als Datei ausgegeben.</p>
*
* <p>IO-Fehler beim Schreiben der Berichtdatei verhindern die Konsolenausgabe nicht.
* Das Ergebnis wird in jedem Fall auf die Konsole geschrieben.</p>
*
* @param args Kommandozeilenargumente
* @return Exit-Code: {@link ExitCode#VALID} (0), {@link ExitCode#INVALID} (1)
* oder {@link ExitCode#OPERATIONAL_ERROR} (2)
*/
public int run(String[] args) {
// --- Fall 1: Kein Argument ---
if (args.length == 0) {
String msg = "Fehler: Kein Dateipfad angegeben. Verwendung: java -jar asv-format-validator.jar <datei-pfad>";
log.error("Bedienfehler: Kein Argument übergeben.");
ValidationReport report = ValidationReport.operationalError(
PLACEHOLDER_NO_ARG, "OPERATIONAL-MISSING-ARG", msg);
// Kein Verzeichnis bekannt → nur Konsole
writeMinimalReportToConsoleOnly(report);
return ExitCode.OPERATIONAL_ERROR;
}
// --- Fall 2: Mehr als ein Argument ---
if (args.length > 1) {
String msg = "Fehler: Zu viele Argumente. Es wird genau ein Dateipfad erwartet.";
log.error("Bedienfehler: Zu viele Argumente ({}). ", args.length);
ValidationReport report = ValidationReport.operationalError(
PLACEHOLDER_MANY_ARGS, "OPERATIONAL-TOO-MANY-ARGS", msg);
// Kein eindeutiges Verzeichnis → nur Konsole
writeMinimalReportToConsoleOnly(report);
return ExitCode.OPERATIONAL_ERROR;
}
String filePath = args[0];
// Pfad parsen
Path path;
try {
path = Paths.get(filePath);
} catch (InvalidPathException e) {
String msg = "Fehler: Ungültiger Dateipfad: " + filePath;
log.error("Bedienfehler: Ungültiger Dateipfad: {}", filePath);
ValidationReport report = ValidationReport.operationalError(
filePath, "OPERATIONAL-FILE-NOT-FOUND", msg);
// Ungültiger Pfad → kein Verzeichnis ableitbar → nur Konsole
writeMinimalReportToConsoleOnly(report);
return ExitCode.OPERATIONAL_ERROR;
}
// --- Fall 3: Datei existiert nicht ---
if (!Files.exists(path)) {
String msg = "Fehler: Datei nicht gefunden: " + filePath;
log.error("Bedienfehler: Datei nicht gefunden: {}", filePath);
String fileBaseName = path.getFileName() != null ? path.getFileName().toString() : "bedienfehler";
ValidationReport report = ValidationReport.operationalError(
fileBaseName, "OPERATIONAL-FILE-NOT-FOUND", msg);
// Übergeordnetes Verzeichnis ableiten und Bericht schreiben, sofern schreibbar
Path parent = path.toAbsolutePath().getParent();
writeMinimalReportWithOptionalFile(report, parent, fileBaseName);
return ExitCode.OPERATIONAL_ERROR;
}
// --- Fall 4: Kein regulärer Dateityp ---
if (!Files.isRegularFile(path)) {
String msg = "Fehler: Pfad ist keine reguläre Datei (z.B. Verzeichnis): " + filePath;
log.error("Bedienfehler: Pfad ist keine reguläre Datei: {}", filePath);
String fileBaseName = path.getFileName() != null ? path.getFileName().toString() : filePath;
ValidationReport report = ValidationReport.operationalError(
fileBaseName, "OPERATIONAL-NOT-REGULAR", msg);
// Kein Minimalbericht als Datei (nur Konsole), da unklar ob Verz. schreibbar
writeMinimalReportToConsoleOnly(report);
return ExitCode.OPERATIONAL_ERROR;
}
// --- Fall 5: Datei nicht lesbar ---
if (!Files.isReadable(path)) {
String msg = "Fehler: Datei ist nicht lesbar (fehlende Leseberechtigung): " + filePath;
log.error("Bedienfehler: Datei nicht lesbar: {}", filePath);
String fileBaseName = path.getFileName() != null ? path.getFileName().toString() : filePath;
ValidationReport report = ValidationReport.operationalError(
fileBaseName, "OPERATIONAL-NOT-READABLE", msg);
// Übergeordnetes Verzeichnis ableiten und Bericht schreiben, sofern schreibbar
Path parent = path.toAbsolutePath().getParent();
writeMinimalReportWithOptionalFile(report, parent, fileBaseName);
return ExitCode.OPERATIONAL_ERROR;
}
// --- Normaler Validierungslauf ---
// Log-Datei bestimmen und Logging umkonfigurieren (AP07)
String baseName = path.getFileName().toString();
Path directory = path.toAbsolutePath().getParent();
if (directory == null) {
directory = Path.of(".");
}
Path logPath = suffixResolver.resolveNextFreePath(directory, baseName, "log");
loggingConfigurator.configureLogFile(logPath);
log.info("ASV-Format-Validator gestartet. Eingabedatei: {}", path.toAbsolutePath());
// Validierung delegieren
ValidationReport report = validationService.validate(path);
Verdict verdict = report.computeVerdict();
log.info("Validierung abgeschlossen. Datei: {}, Urteil: {}", path.getFileName(), verdict);
// Berichtdatei schreiben (AP07)
ReportFileWriter.ReportWriteResult writeResult = reportFileWriter.write(report, path);
if (!writeResult.isSuccess()) {
log.error("Berichtdatei konnte nicht geschrieben werden: {}",
writeResult.writeException() != null
? writeResult.writeException().getMessage() : "unbekannter Fehler");
}
// Konsolenausgabe (immer, auch bei Schreibfehler der Datei)
System.out.print(writeResult.reportContent());
return switch (verdict) {
case VALID -> ExitCode.VALID;
case INVALID -> ExitCode.INVALID;
case OPERATIONAL_ERROR -> ExitCode.OPERATIONAL_ERROR;
};
}
/**
* Gibt den Minimalbericht ausschließlich auf der Konsole (STDERR) aus.
* Wird verwendet, wenn kein Zielverzeichnis bekannt oder sinnvoll ableitbar ist.
*
* @param report der Bedienfehler-Bericht (nicht null)
*/
private void writeMinimalReportToConsoleOnly(ValidationReport report) {
String content = reportFileWriter.buildMinimalReportContent(report);
System.err.print(content);
}
/**
* Gibt den Minimalbericht auf der Konsole (STDERR) aus und versucht zusätzlich,
* ihn als Datei in das angegebene Verzeichnis zu schreiben.
*
* <p>Ist das Verzeichnis nicht vorhanden oder nicht schreibbar, wird nur eine
* Hinweiszeile auf STDERR ausgegeben — kein Fehler auf Fehler.</p>
*
* @param report der Bedienfehler-Bericht (nicht null)
* @param directory das Zielverzeichnis; kann {@code null} sein
* @param baseName Basisname für die Berichtdatei
*/
private void writeMinimalReportWithOptionalFile(ValidationReport report,
Path directory,
String baseName) {
String content = reportFileWriter.buildMinimalReportContent(report);
System.err.print(content);
if (directory == null || !Files.isDirectory(directory) || !Files.isWritable(directory)) {
System.err.println("Bericht konnte nicht in das Verzeichnis geschrieben werden.");
log.warn("Bedienfehler-Bericht konnte nicht als Datei geschrieben werden: " +
"Verzeichnis nicht vorhanden oder nicht schreibbar: {}", directory);
return;
}
ReportFileWriter.ReportWriteResult result =
reportFileWriter.writeOperationalError(report, directory, baseName);
if (result.isSuccess()) {
log.info("Bedienfehler-Bericht geschrieben: {}", result.reportPath());
System.err.println("Bericht geschrieben: " + result.reportPath());
} else {
System.err.println("Bericht konnte nicht in das Verzeichnis geschrieben werden.");
log.warn("Bedienfehler-Bericht konnte nicht geschrieben werden: {}",
result.writeException() != null
? result.writeException().getMessage() : "unbekannter Fehler");
}
}
}
@@ -0,0 +1,32 @@
package de.gecheckt.asv.adapter.in.cli;
/**
* Normative Exit-Codes der ASV-Format-Validator-CLI.
*
* <p>Die drei zulässigen Exit-Codes sind gemäß Technischer Anlage ASV 1.09 und
* {@code docs/specs/technik-und-architektur.md} definiert:</p>
* <ul>
* <li>{@link #VALID} (0) — Datei ist spec-konform, keine SPEC-ERROR-Befunde</li>
* <li>{@link #INVALID} (1) — Datei enthält mindestens einen SPEC-ERROR-Befund</li>
* <li>{@link #OPERATIONAL_ERROR} (2) — Bedienfehler (fehlendes Argument, nicht lesbare Datei)</li>
* </ul>
*
* <p>Exit-Code 3 existiert nicht mehr. Die früheren Konstanten
* {@code EXIT_CODE_INVALID_ARGUMENTS}, {@code EXIT_CODE_FILE_ERROR} und
* {@code EXIT_CODE_VALIDATION_ERRORS} wurden mit AP06 entfernt.</p>
*/
public final class ExitCode {
/** Datei ist gültig (keine SPEC-ERROR-Befunde). */
public static final int VALID = 0;
/** Datei enthält mindestens einen SPEC-ERROR-Befund. */
public static final int INVALID = 1;
/** Bedienfehler: falsches Argument, nicht lesbare Datei o. Ä. */
public static final int OPERATIONAL_ERROR = 2;
private ExitCode() {
// Nicht instanziierbar
}
}
@@ -0,0 +1,87 @@
package de.gecheckt.asv.adapter.out.filesystem;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Ermittelt den nächsten freien Dateipfad im Zielverzeichnis anhand von Basisname und Extension.
*
* <p>Die Suffix-Logik arbeitet extension-unabhängig: Für {@code .txt} und {@code .log} werden
* separate Zähler geführt. Beim ersten Lauf entsteht {@code <baseName>.<ext>}; bei jedem
* weiteren Lauf wird {@code <baseName>_v1.<ext>}, {@code <baseName>_v2.<ext>} usw. erzeugt,
* bis ein freier Pfad gefunden ist.</p>
*
* <p>Dieses Objekt ist zustandslos. Alle Methoden können nebenläufig auf verschiedenen
* Eingabedatei-Pfaden verwendet werden; Race Conditions bei gleichzeitigen Läufen auf
* derselben Eingabedatei sind in V1 bewusst nicht behandelt.</p>
*/
public class SuffixResolver {
/**
* Ermittelt den ersten freien Dateipfad für den gegebenen Basisnamen und die gegebene
* Extension im Zielverzeichnis.
*
* <p>Probiert in dieser Reihenfolge:</p>
* <ol>
* <li>{@code <baseName>.<extension>}</li>
* <li>{@code <baseName>_v1.<extension>}</li>
* <li>{@code <baseName>_v2.<extension>}</li>
* <li>… bis ein freier Pfad gefunden ist</li>
* </ol>
*
* <p>Die Zählung ist pro Extension unabhängig: Eine vorhandene {@code foo.auf.txt} hat
* keinen Einfluss auf die Zählung für {@code foo.auf.log}.</p>
*
* @param directory das Zielverzeichnis (muss existieren)
* @param baseName Basisname ohne Extension (z.B. {@code "foo.auf"})
* @param extension Extension ohne führenden Punkt (z.B. {@code "txt"})
* @return der erste freie Pfad (existiert noch nicht im Dateisystem)
* @throws IllegalArgumentException wenn {@code directory}, {@code baseName} oder
* {@code extension} null oder leer sind
* @throws UncheckedIOException wenn der Dateisystem-Zugriff fehlschlägt
*/
public Path resolveNextFreePath(Path directory, String baseName, String extension) {
if (directory == null) {
throw new IllegalArgumentException("directory darf nicht null sein");
}
if (baseName == null || baseName.isBlank()) {
throw new IllegalArgumentException("baseName darf nicht null oder leer sein");
}
if (extension == null || extension.isBlank()) {
throw new IllegalArgumentException("extension darf nicht null oder leer sein");
}
// Kandidat ohne Suffix: <baseName>.<ext>
Path candidate = directory.resolve(baseName + "." + extension);
if (!exists(candidate)) {
return candidate;
}
// Mit Suffix: <baseName>_v1.<ext>, <baseName>_v2.<ext>, ...
int counter = 1;
while (true) {
candidate = directory.resolve(baseName + "_v" + counter + "." + extension);
if (!exists(candidate)) {
return candidate;
}
counter++;
}
}
/**
* Prüft, ob der Pfad im Dateisystem existiert.
*
* @param path der zu prüfende Pfad
* @return {@code true} wenn die Datei existiert
*/
private boolean exists(Path path) {
try {
return Files.exists(path);
} catch (SecurityException e) {
throw new UncheckedIOException(
new IOException("Dateisystem-Zugriff verweigert: " + path, e));
}
}
}
@@ -1,21 +1,86 @@
package de.gecheckt.asv.adapter.out.logging;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.FileAppender;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.LoggerConfig;
import org.apache.logging.log4j.core.layout.PatternLayout;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
/**
* Konfiguriert den Log4j2-Logging-Adapter programmatisch.
* Erlaubt es, den Zielpfad der Log-Datei zur Laufzeit zu setzen.
* Log4j2-Typen dürfen in diesem Paket direkt verwendet werden.
*
* <p>Erlaubt es, den Zielpfad der Log-Datei zur Laufzeit zu setzen, bevor der erste
* fachliche Log-Aufruf erfolgt. Log4j2-Typen dürfen ausschließlich in diesem Paket
* ({@code adapter.out.logging}) und in {@code bootstrap} sichtbar sein.</p>
*
* <p>Implementierungsansatz: programmatische Umkonfiguration über die Log4j2 Configurator-API.
* Ein neuer {@link FileAppender} wird erzeugt und dem Root-Logger sowie dem
* {@code de.gecheckt.asv}-Logger hinzugefügt. Der statische Fallback-Appender aus
* {@code log4j2.xml} (Datei {@code logs/asv-format-validator.log}) bleibt als Fallback
* bestehen, wenn diese Methode nicht aufgerufen wird (z.B. in reinen Unit-Tests).</p>
*/
public class LoggingConfigurator {
private static final String PATTERN =
"%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n";
/**
* Setzt den Zielpfad der Log-Datei für diesen Lauf.
* Die tatsächliche dynamische Umleitung wird in AP07 implementiert.
* Setzt den Zielpfad der Log-Datei für diesen Lauf und konfiguriert Log4j2
* programmatisch um.
*
* @param logFile Pfad zur Log-Datei
* <p>Diese Methode muss <strong>vor</strong> dem ersten fachlichen Log-Aufruf
* aufgerufen werden. Nach dem Aufruf gehen alle Log-Nachrichten sowohl in die
* Konsole (STDERR, über den bestehenden Console-Appender) als auch in die
* angegebene Datei.</p>
*
* <p>Schlägt die Umkonfiguration fehl (z.B. wegen eines SecurityManagers oder
* inkompatiblem Log4j2-Zustand), wird ein Fallback-Warnung auf STDERR ausgegeben;
* der laufende Betrieb wird nicht unterbrochen — Logs gehen dann nur in den
* Fallback-Appender aus {@code log4j2.xml}.</p>
*
* @param logFile Zielpfad der Log-Datei (nicht null)
* @throws IllegalArgumentException wenn {@code logFile} null ist
*/
public void configureLogFile(Path logFile) {
// TODO: dynamische Log-Datei-Umleitung in AP07
Objects.requireNonNull(logFile, "logFile darf nicht null sein");
try {
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
Configuration config = ctx.getConfiguration();
PatternLayout layout = PatternLayout.newBuilder()
.withPattern(PATTERN)
.withConfiguration(config) // withConfiguration ist in PatternLayout.Builder nicht deprecated
.build();
FileAppender fileAppender = FileAppender.newBuilder()
.withFileName(logFile.toAbsolutePath().toString())
.withAppend(false)
.setName("DynamicFile")
.setLayout(layout)
.setConfiguration(config)
.build();
fileAppender.start();
config.addAppender(fileAppender);
// Appender dem de.gecheckt.asv-Logger hinzufügen (falls vorhanden)
Map<String, LoggerConfig> loggers = config.getLoggers();
for (LoggerConfig loggerConfig : loggers.values()) {
loggerConfig.addAppender(fileAppender, null, null);
}
ctx.updateLoggers();
} catch (Exception e) {
System.err.println("[LoggingConfigurator] Warnung: Programmatische Log4j2-Umkonfiguration"
+ " fehlgeschlagen. Logs gehen nur in den Fallback-Appender. Ursache: "
+ e.getMessage());
}
}
}
@@ -0,0 +1,258 @@
package de.gecheckt.asv.adapter.out.reporting;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.domain.finding.Finding;
import de.gecheckt.asv.domain.finding.ValidationReport;
import de.gecheckt.asv.domain.finding.Verdict;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
/**
* Schreibt den Validierungsbericht in eine UTF-8-Textdatei im Verzeichnis der Eingabedatei.
*
* <p>Der Dateiname wird über {@link SuffixResolver} bestimmt: Basisname ist der vollständige
* Dateiname der Eingabedatei inklusive Extension (z.B. {@code foo.auf}); die Ausgabedatei
* heißt dann {@code foo.auf.txt}, beim zweiten Lauf {@code foo.auf_v1.txt} usw.</p>
*
* <p>Der Bericht ist für M1 absichtlich minimal strukturiert und wird in M9 durch die
* finale hierarchische Gliederung ersetzt. Alle Texte auf Deutsch, Encoding explizit UTF-8.</p>
*
* <p>Dieses Objekt enthält keinerlei Log4j2-Typen — Logging erfolgt ausschließlich über
* die SLF4J-Fassade.</p>
*/
public class ReportFileWriter {
private static final Logger log = LoggerFactory.getLogger(ReportFileWriter.class);
private static final DateTimeFormatter TIMESTAMP_FORMATTER =
DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneOffset.UTC);
private final SuffixResolver suffixResolver;
/**
* Erzeugt einen neuen {@code ReportFileWriter} mit dem angegebenen {@link SuffixResolver}.
*
* @param suffixResolver Resolver für den nächsten freien Dateipfad (nicht null)
*/
public ReportFileWriter(SuffixResolver suffixResolver) {
this.suffixResolver = Objects.requireNonNull(suffixResolver,
"suffixResolver darf nicht null sein");
}
/**
* Schreibt den Validierungsbericht als UTF-8-Textdatei in das Verzeichnis der Eingabedatei.
*
* <p>Gibt den Inhalt der Berichtdatei als String zurück, damit der Aufrufer ihn
* identisch auf der Konsole ausgeben kann. Ist das Schreiben der Datei nicht möglich,
* wird die Ausnahme protokolliert und die Methode gibt einen Fehlerbericht-String zurück —
* der Aufrufer kann die Konsolenausgabe trotzdem ausgeben.</p>
*
* @param report der Validierungsbericht (nicht null)
* @param inputFilePath Pfad zur Eingabedatei; bestimmt Verzeichnis und Basisnamen (nicht null)
* @return Pfad zur erzeugten Berichtdatei; {@code null} wenn die Datei nicht geschrieben werden konnte
* @throws IllegalArgumentException wenn {@code report} oder {@code inputFilePath} null sind
*/
public ReportWriteResult write(ValidationReport report, Path inputFilePath) {
if (report == null) {
throw new IllegalArgumentException("report darf nicht null sein");
}
if (inputFilePath == null) {
throw new IllegalArgumentException("inputFilePath darf nicht null sein");
}
// Basisname ist der vollständige Dateiname der Eingabedatei inkl. Extension
String baseName = inputFilePath.getFileName().toString();
Path directory = inputFilePath.toAbsolutePath().getParent();
if (directory == null) {
directory = Path.of(".");
}
// Berichtinhalt aufbauen
String content = buildReportContent(report, inputFilePath);
// Zieldatei bestimmen
Path reportPath = suffixResolver.resolveNextFreePath(directory, baseName, "txt");
// Schreiben
try (Writer writer = Files.newBufferedWriter(reportPath, StandardCharsets.UTF_8)) {
writer.write(content);
log.info("Berichtdatei geschrieben: {}", reportPath);
} catch (IOException e) {
log.error("Fehler beim Schreiben der Berichtdatei {}: {}", reportPath, e.getMessage());
return new ReportWriteResult(content, null, e);
}
return new ReportWriteResult(content, reportPath, null);
}
/**
* Schreibt einen Bedienfehler-Bericht als UTF-8-Textdatei in das angegebene Verzeichnis.
*
* <p>Im Gegensatz zu {@link #write(ValidationReport, Path)} ist diese Methode für den
* Fall gedacht, dass keine Eingabedatei existiert (z.B. Datei nicht gefunden). Sie nimmt
* das Verzeichnis und den Basisnamen direkt entgegen.</p>
*
* <p>IO-Fehler führen <em>nicht</em> zu einer {@link RuntimeException}, sondern werden
* protokolliert. Das Ergebnis signalisiert den Fehler über {@link ReportWriteResult}.</p>
*
* @param report der Bedienfehler-Bericht (nicht null)
* @param directory das Zielverzeichnis (nicht null, muss existieren und schreibbar sein)
* @param baseName Basisname für die Berichtdatei (nicht null, nicht leer)
* @return Schreibergebnis; {@link ReportWriteResult#isSuccess()} gibt an, ob erfolgreich
*/
public ReportWriteResult writeOperationalError(ValidationReport report,
Path directory,
String baseName) {
Objects.requireNonNull(report, "report darf nicht null sein");
Objects.requireNonNull(directory, "directory darf nicht null sein");
if (baseName == null || baseName.isBlank()) {
throw new IllegalArgumentException("baseName darf nicht null oder leer sein");
}
String content = buildMinimalReportContent(report);
Path reportPath = suffixResolver.resolveNextFreePath(directory, baseName, "txt");
try (java.io.Writer writer = java.nio.file.Files.newBufferedWriter(
reportPath, java.nio.charset.StandardCharsets.UTF_8)) {
writer.write(content);
log.info("Bedienfehler-Bericht geschrieben: {}", reportPath);
} catch (IOException e) {
log.error("Fehler beim Schreiben des Bedienfehler-Berichts {}: {}", reportPath, e.getMessage());
return new ReportWriteResult(content, null, e);
}
return new ReportWriteResult(content, reportPath, null);
}
/**
* Erstellt den Berichtinhalt für einen Bedienfehler-Bericht als formatierten String.
*
* <p>Wird von {@link de.gecheckt.asv.adapter.in.cli.CliRunner} direkt verwendet, wenn
* der Inhalt für die Konsolenausgabe benötigt wird, ohne eine Datei zu schreiben.
* Im Unterschied zu {@link #buildReportContent(ValidationReport, Path)} wird der
* Dateiname aus dem Bericht direkt als String verwendet — kein {@link Path}-Parsing,
* sodass auch Platzhalter wie {@code <kein Argument>} mit Sonderzeichen funktionieren.</p>
*
* @param report der Bedienfehler-Bericht (nicht null)
* @return der vollständige Berichtinhalt als String
*/
public String buildMinimalReportContent(ValidationReport report) {
Objects.requireNonNull(report, "report darf nicht null sein");
return buildReportContentWithFileName(report, report.getFileName());
}
/**
* Erstellt den Berichtinhalt als formatierten String (delegiert an die String-Variante).
*
* @param report der Validierungsbericht
* @param inputFilePath Pfad zur Eingabedatei
* @return der vollständige Berichtinhalt
*/
String buildReportContent(ValidationReport report, Path inputFilePath) {
return buildReportContentWithFileName(report, inputFilePath.toAbsolutePath().toString());
}
/**
* Erstellt den Berichtinhalt als formatierten String mit dem Dateinamen als String.
*
* <p>Diese Variante wird für Bedienfehler-Berichte verwendet, bei denen der Dateiname
* ein Platzhalter wie {@code <kein Argument>} sein kann, der kein gültiger Pfad ist.</p>
*
* @param report der Validierungsbericht
* @param fileNameDisplay der anzuzeigende Dateiname (als String, nicht als Pfad)
* @return der vollständige Berichtinhalt
*/
private String buildReportContentWithFileName(ValidationReport report, String fileNameDisplay) {
StringBuilder sb = new StringBuilder();
Verdict verdict = report.computeVerdict();
// Kopfzeile
sb.append("================================================================\n");
sb.append("ASV-Format-Validator Prüfbericht\n");
sb.append("================================================================\n");
sb.append("Zeitstempel : ").append(TIMESTAMP_FORMATTER.format(report.getTimestamp())).append("\n");
sb.append("Eingabedatei: ").append(fileNameDisplay).append("\n");
sb.append("Urteil : ").append(verdictText(verdict)).append("\n");
sb.append("----------------------------------------------------------------\n");
// Befunde
List<Finding> findings = report.getFindings();
if (findings.isEmpty()) {
sb.append("Keine Befunde.\n");
} else {
sb.append("Befunde (").append(findings.size()).append("):\n");
for (Finding f : findings) {
sb.append(" [")
.append(f.severity())
.append("] [")
.append(f.kind())
.append("] [")
.append(f.layer())
.append("]");
if (f.ruleId() != null) {
sb.append(" Regel=").append(f.ruleId());
}
if (f.fieldId() != null) {
sb.append(" Feld=").append(f.fieldId());
}
sb.append(" ").append(f.germanMessage()).append("\n");
}
}
// Fußzeile
sb.append("----------------------------------------------------------------\n");
sb.append("Hinweis: Dieser Bericht wurde mit dem M1-Platzhalter-Validator\n");
sb.append("erzeugt. Viele Prüfbereiche (Fachmodell, Inhalt, Referenzdaten)\n");
sb.append("werden erst ab M3 aktiv geprüft.\n");
sb.append("================================================================\n");
return sb.toString();
}
/**
* Gibt den deutschen Urteil-Text für das übergebene Verdict zurück.
*
* @param verdict das Prüfurteil
* @return deutschsprachige Urteilsbezeichnung
*/
private static String verdictText(Verdict verdict) {
return switch (verdict) {
case VALID -> "GÜLTIG";
case INVALID -> "UNGÜLTIG";
case OPERATIONAL_ERROR -> "BEDIENFEHLER";
};
}
/**
* Ergebnis eines Schreibvorgangs.
*
* @param reportContent der erzeugte Berichtinhalt als String (niemals null)
* @param reportPath Pfad zur erzeugten Datei; {@code null} bei Schreibfehler
* @param writeException aufgetretene Ausnahme beim Schreiben; {@code null} bei Erfolg
*/
public record ReportWriteResult(
String reportContent,
Path reportPath,
IOException writeException
) {
/**
* Gibt an, ob die Datei erfolgreich geschrieben wurde.
*
* @return {@code true} wenn {@link #reportPath()} nicht null ist
*/
public boolean isSuccess() {
return reportPath != null;
}
}
}
@@ -0,0 +1,54 @@
package de.gecheckt.asv.application;
import de.gecheckt.asv.domain.finding.ValidationReport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
/**
* M1-Platzhalter-Implementierung des {@link FileValidationService}.
*
* <p>Liest die Eingabedatei mit dem normativen Eingabe-Encoding <strong>ISO-8859-15</strong>
* ein, zählt die gelesenen Bytes und gibt einen leeren {@link ValidationReport} zurück.
* <em>Keine echte Validierung wird in M1 durchgeführt.</em></p>
*
* <p>Diese Klasse wird in M3 durch eine echte Implementierung ersetzt, die den
* vollständigen Parser- und Validator-Pfad aktiviert.</p>
*/
public class DummyFileValidationService implements FileValidationService {
/** Normatives Eingabe-Encoding gemäß Technischer Anlage ASV 1.09. */
static final Charset INPUT_CHARSET = Charset.forName("ISO-8859-15");
private static final Logger log = LoggerFactory.getLogger(DummyFileValidationService.class);
/**
* Liest die Datei mit ISO-8859-15 ein, zählt die Bytes und gibt einen leeren
* Validierungsbericht zurück.
*
* @param inputFile Pfad zur Eingabedatei (nicht null, vorab als existent/lesbar geprüft)
* @return leerer {@link ValidationReport} mit Dateiname und aktuellem Zeitstempel
*/
@Override
public ValidationReport validate(Path inputFile) {
String fileName = inputFile.getFileName().toString();
try {
byte[] rawBytes = Files.readAllBytes(inputFile);
String content = new String(rawBytes, INPUT_CHARSET);
log.info("M1-Dummy: Datei '{}' gelesen ({} Bytes, {} Zeichen, Encoding: ISO-8859-15)",
fileName, rawBytes.length, content.length());
} catch (IOException e) {
log.warn("M1-Dummy: Lesefehler bei '{}': {}", fileName, e.getMessage());
}
return new ValidationReport(fileName, Instant.now(), List.of());
}
}
@@ -0,0 +1,29 @@
package de.gecheckt.asv.application;
import de.gecheckt.asv.domain.finding.ValidationReport;
import java.nio.file.Path;
/**
* Anwendungsschnittstelle für die Dateivalidierung.
*
* <p>Nimmt einen bereits vorab geprüften (existierenden, regulären, lesbaren) Dateipfad
* entgegen und gibt einen {@link ValidationReport} zurück.</p>
*
* <p>In M1 liefert die Standardimplementierung ({@code DummyFileValidationService}) einen
* leeren Bericht. Echte Parser- und Validator-Einbindung folgt ab M3.</p>
*/
public interface FileValidationService {
/**
* Validiert die Datei unter dem angegebenen Pfad.
*
* <p>Der Pfad wird als vorab geprüft betrachtet (existent, reguläre Datei, lesbar).
* Die Implementierung muss stets einen nicht-{@code null}-{@link ValidationReport}
* zurückgeben.</p>
*
* @param inputFile Pfad zur zu validierenden Eingabedatei (nicht null)
* @return Validierungsbericht (nicht null)
*/
ValidationReport validate(Path inputFile);
}
@@ -10,13 +10,21 @@ import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.application.model.ValidationSeverity;
/**
* Default implementation of FieldValidator that checks general field rules.
*
* Rules checked:
* 1. Field.rawValue must not be empty
* 2. Field.rawValue must not consist only of whitespaces
* 3. Field positions within a segment should be consecutive without gaps, starting at 1
* 4. If fieldName is set, it must not be empty or only whitespace
* M3-Vorbau. In M1 bewusst nicht im produktiven Lauf verdrahtet.
* Wird ab M3 wieder aktiviert und gegen die finalen Regelklassifikationen
* (V1-V/T/N/K) aus fachliche-anforderungen.md bewertet.
*
* @see <a href="docs/arbeitspakete/m1/E00-entscheidungsprotokoll.md">E-01</a>
*
* <p>Standardimplementierung des FieldValidator, die allgemeine Feldregeln prüft.</p>
*
* <p>Geprüfte Regeln:</p>
* <ol>
* <li>Field.rawValue darf nicht leer sein</li>
* <li>Field.rawValue darf nicht nur aus Leerzeichen bestehen</li>
* <li>Feldpositionen innerhalb eines Segments sollten lückenlos aufeinanderfolgen, beginnend bei 1</li>
* <li>Wenn fieldName gesetzt ist, darf er nicht leer oder nur aus Leerzeichen bestehen</li>
* </ol>
*/
public class DefaultFieldValidator implements FieldValidator {
@@ -13,28 +13,36 @@ import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.application.model.ValidationSeverity;
/**
* Standardimplementierung des StructureValidator, die allgemeine Strukturregeln prüft.
*
* Geprüfte Regeln:
* 1. Die Eingabedatei muss mindestens eine Nachricht enthalten
* 2. Jede Nachricht muss mindestens ein Segment enthalten
* 3. Segmentnamen dürfen nicht leer sein
* 4. Feldpositionen innerhalb eines Segments müssen eindeutig und positiv sein
* 5. Segmentpositionen innerhalb einer Nachricht müssen eindeutig und positiv sein
* 6. Nachrichtenpositionen innerhalb einer Eingabedatei müssen eindeutig und positiv sein
* 7. UNH- und UNT-Referenznummern müssen innerhalb einer Nachricht übereinstimmen
* 8. Die im UNT angegebene Segmentanzahl muss der tatsächlichen Anzahl der Segmente entsprechen
* 9. Eine Nachricht muss mindestens ein UNH-Segment enthalten
* 10. Eine Nachricht muss mindestens ein UNT-Segment enthalten
* 11. UNH muss vor UNT stehen
* 12. Der Nachrichtentyp in UNH/S009/0065 darf nur ASVREC oder ASVFEH sein
* 13. Für Nachrichten vom Typ ASVREC müssen die Segmente IFA, REA und IVA vorhanden sein
* 14. Für Nachrichten vom Typ ASVREC muss die Reihenfolge IFA vor REA vor IVA eingehalten werden
* 15. Für ASVREC mit Rechnungskennzeichen "0" in REA müssen DGN und LEA vorhanden sein
* 16. Für ASVREC mit Rechnungskennzeichen "1" in REA dürfen DGN und LEA nicht vorhanden sein
* 17. Für ASVREC mit Rechnungskennzeichen "1" in REA muss der Rechnungsbetrag "0,00" sein
* 18. Für ASVREC müssen IFA, REA und IVA jeweils genau einmal vorkommen
* 19. Für ASVFEH-Nachrichten muss mindestens ein FHL-Segment vorhanden sein
* M3-Vorbau. In M1 bewusst nicht im produktiven Lauf verdrahtet.
* Wird ab M3 wieder aktiviert und gegen die finalen Regelklassifikationen
* (V1-V/T/N/K) aus fachliche-anforderungen.md bewertet.
*
* @see <a href="docs/arbeitspakete/m1/E00-entscheidungsprotokoll.md">E-01</a>
*
* <p>Standardimplementierung des StructureValidator, die allgemeine Strukturregeln prüft.</p>
*
* <p>Geprüfte Regeln:</p>
* <ol>
* <li>Die Eingabedatei muss mindestens eine Nachricht enthalten</li>
* <li>Jede Nachricht muss mindestens ein Segment enthalten</li>
* <li>Segmentnamen dürfen nicht leer sein</li>
* <li>Feldpositionen innerhalb eines Segments müssen eindeutig und positiv sein</li>
* <li>Segmentpositionen innerhalb einer Nachricht müssen eindeutig und positiv sein</li>
* <li>Nachrichtenpositionen innerhalb einer Eingabedatei müssen eindeutig und positiv sein</li>
* <li>UNH- und UNT-Referenznummern müssen innerhalb einer Nachricht übereinstimmen</li>
* <li>Die im UNT angegebene Segmentanzahl muss der tatsächlichen Anzahl der Segmente entsprechen</li>
* <li>Eine Nachricht muss mindestens ein UNH-Segment enthalten</li>
* <li>Eine Nachricht muss mindestens ein UNT-Segment enthalten</li>
* <li>UNH muss vor UNT stehen</li>
* <li>Der Nachrichtentyp in UNH/S009/0065 darf nur ASVREC oder ASVFEH sein</li>
* <li>Für Nachrichten vom Typ ASVREC müssen die Segmente IFA, REA und IVA vorhanden sein</li>
* <li>Für Nachrichten vom Typ ASVREC muss die Reihenfolge IFA vor REA vor IVA eingehalten werden</li>
* <li>Für ASVREC mit Rechnungskennzeichen "0" in REA müssen DGN und LEA vorhanden sein</li>
* <li>Für ASVREC mit Rechnungskennzeichen "1" in REA dürfen DGN und LEA nicht vorhanden sein</li>
* <li>Für ASVREC mit Rechnungskennzeichen "1" in REA muss der Rechnungsbetrag "0,00" sein</li>
* <li>Für ASVREC müssen IFA, REA und IVA jeweils genau einmal vorkommen</li>
* <li>Für ASVFEH-Nachrichten muss mindestens ein FHL-Segment vorhanden sein</li>
* </ol>
*/
public class DefaultStructureValidator implements StructureValidator {
@@ -0,0 +1,64 @@
package de.gecheckt.asv.bootstrap;
import de.gecheckt.asv.adapter.in.cli.CliRunner;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.adapter.out.logging.LoggingConfigurator;
import de.gecheckt.asv.adapter.out.reporting.ReportFileWriter;
import de.gecheckt.asv.application.DummyFileValidationService;
import de.gecheckt.asv.application.FileValidationService;
/**
* Einziger {@code public static void main}-Einstiegspunkt des ASV-Format-Validators.
*
* <p>Verantwortlichkeiten:</p>
* <ol>
* <li>Manuelle Constructor Injection aller Anwendungskomponenten</li>
* <li>Logging-Konfiguration über {@link LoggingConfigurator} (Log-Datei im Eingabeverzeichnis)</li>
* <li>Delegation an {@link CliRunner#run(String[], ReportFileWriter)}</li>
* <li>Weiterreichen des Exit-Codes an {@link System#exit(int)}</li>
* </ol>
*
* <p><strong>Log4j2-Sichtbarkeit:</strong> Nur dieses Paket ({@code bootstrap}) und
* {@code adapter.out.logging} dürfen Log4j2-Typen direkt verwenden.</p>
*
* <p><strong>Reihenfolge vor dem Validierungslauf (AP07):</strong></p>
* <ol>
* <li>Eingabedatei-Pfad aus Argumenten bestimmen (in {@link CliRunner})</li>
* <li>Basisname und Zielverzeichnis ableiten</li>
* <li>{@link SuffixResolver} für {@code .log} aufrufen</li>
* <li>{@link LoggingConfigurator#configureLogFile(java.nio.file.Path)} aufrufen</li>
* <li>Validierungslauf starten</li>
* <li>{@link ReportFileWriter} schreibt Berichtdatei</li>
* <li>Konsolenausgabe (identisch zum Berichtinhalt)</li>
* </ol>
*/
public final class Main {
private Main() {
// Nicht instanziierbar
}
/**
* Startpunkt der CLI-Anwendung.
*
* @param args Kommandozeilenargumente; erwartet genau einen Dateipfad
*/
public static void main(String[] args) {
// Infrastruktur-Objekte (zustandslos)
LoggingConfigurator loggingConfigurator = new LoggingConfigurator();
SuffixResolver suffixResolver = new SuffixResolver();
ReportFileWriter reportFileWriter = new ReportFileWriter(suffixResolver);
// Manuelle Constructor Injection
FileValidationService validationService = new DummyFileValidationService();
CliRunner cliRunner = new CliRunner(
validationService,
loggingConfigurator,
suffixResolver,
reportFileWriter);
// Ausführen und Exit-Code weiterreichen
int exitCode = cliRunner.run(args);
System.exit(exitCode);
}
}
@@ -0,0 +1,37 @@
package de.gecheckt.asv.bootstrap;
import java.util.List;
import de.gecheckt.asv.application.field.FieldValidator;
import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.domain.model.InputFile;
/**
* M1-Platzhalter für den Feldvalidator.
*
* <p>Erzeugt keinerlei Befunde — der Validator ist in M1 bewusst ohne fachliche Prüflogik
* verdrahtet, damit kein aktiver Lauf ASVREC-/ASVFEH-Feldbefunde liefert.</p>
*
* <p>Ab M3 durch {@link de.gecheckt.asv.application.field.DefaultFieldValidator}
* ersetzen, sobald die Regelklassifikationen (V1-V/T/N/K) aus
* {@code docs/specs/fachliche-anforderungen.md} abgestimmt sind.</p>
*
* @see de.gecheckt.asv.application.field.DefaultFieldValidator
*/
public final class NoOpFieldValidator implements FieldValidator {
/**
* Gibt stets ein leeres Validierungsergebnis zurück.
*
* @param inputFile die Eingabedatei (wird nicht ausgewertet)
* @return leeres {@link ValidationResult} ohne Fehler
* @throws IllegalArgumentException wenn inputFile null ist
*/
@Override
public ValidationResult validate(InputFile inputFile) {
if (inputFile == null) {
throw new IllegalArgumentException("inputFile darf nicht null sein");
}
return new ValidationResult(List.of()); // bewusst leer in M1
}
}
@@ -0,0 +1,37 @@
package de.gecheckt.asv.bootstrap;
import java.util.List;
import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.application.structure.StructureValidator;
import de.gecheckt.asv.domain.model.InputFile;
/**
* M1-Platzhalter für den Strukturvalidator.
*
* <p>Erzeugt keinerlei Befunde — der Validator ist in M1 bewusst ohne fachliche Prüflogik
* verdrahtet, damit kein aktiver Lauf ASVREC-/ASVFEH-Strukturbefunde liefert.</p>
*
* <p>Ab M3 durch {@link de.gecheckt.asv.application.structure.DefaultStructureValidator}
* ersetzen, sobald die Regelklassifikationen (V1-V/T/N/K) aus
* {@code docs/specs/fachliche-anforderungen.md} abgestimmt sind.</p>
*
* @see de.gecheckt.asv.application.structure.DefaultStructureValidator
*/
public final class NoOpStructureValidator implements StructureValidator {
/**
* Gibt stets ein leeres Validierungsergebnis zurück.
*
* @param inputFile die Eingabedatei (wird nicht ausgewertet)
* @return leeres {@link ValidationResult} ohne Fehler
* @throws IllegalArgumentException wenn inputFile null ist
*/
@Override
public ValidationResult validate(InputFile inputFile) {
if (inputFile == null) {
throw new IllegalArgumentException("inputFile darf nicht null sein");
}
return new ValidationResult(List.of()); // bewusst leer in M1
}
}
@@ -0,0 +1,173 @@
package de.gecheckt.asv.domain.finding;
import java.util.Objects;
/**
* Einzelbefund eines Validierungslaufs.
*
* <p>Ein Befund trägt alle Meta-Informationen, die für Berichtserzeugung und spätere
* GUI-Darstellung benötigt werden. Nullable-Felder sind explizit so gekennzeichnet —
* sie sind optional, weil nicht jeder Befund auf ein konkretes Segment oder Feld
* zurückführbar ist.</p>
*
* <p>Unveränderlich (Record). Alle nicht-nullable Felder werden im Konstruktor
* auf {@code null} geprüft.</p>
*
* @param kind Befundart: {@link FindingKind#SPEC} oder {@link FindingKind#DIAGNOSTIC}
* @param severity Schweregrad: ERROR, WARNING oder HINT
* @param layer Schicht, auf die sich der Befund bezieht
* @param ruleId interne Regel-ID; kann {@code null} sein
* @param officialErrorCode offizieller Spec-Fehlercode gemäß Spezifikation Abschnitt 7;
* kann {@code null} sein, wenn kein direkter Fehlercode zugeordnet ist
* @param segmentType Segmentbezeichnung, z.B. {@code "UNB"}; kann {@code null} sein
* @param segmentIndex Null-basierter Index des Segments in der Datei; kann {@code null} sein
* @param fieldId Feld-ID, z.B. {@code "UNB_0020"}; kann {@code null} sein
* @param rawValue Rohwert des betroffenen Felds oder Segments; kann {@code null} sein
* @param position Byte- oder Zeichenposition in der Eingabedatei; kann {@code null} sein
* @param messageReference UNH 0062-Referenz bei Nachrichtenbezug; kann {@code null} sein
* @param germanMessage deutschsprachiger Befundtext; darf <em>nicht</em> {@code null} sein
*/
public record Finding(
FindingKind kind,
Severity severity,
FindingLayer layer,
String ruleId,
String officialErrorCode,
String segmentType,
Integer segmentIndex,
String fieldId,
String rawValue,
Integer position,
String messageReference,
String germanMessage
) {
/**
* Kompaktkonstruktor mit Null-Prüfung für alle Pflichtfelder.
*/
public Finding {
Objects.requireNonNull(kind, "kind darf nicht null sein");
Objects.requireNonNull(severity, "severity darf nicht null sein");
Objects.requireNonNull(layer, "layer darf nicht null sein");
Objects.requireNonNull(germanMessage, "germanMessage darf nicht null sein");
}
// ---------------------------------------------------------------------------
// Hilfsmethoden
// ---------------------------------------------------------------------------
/**
* Gibt zurück, ob es sich um einen SPEC-ERROR-Befund handelt.
* Nur solche Befunde beeinflussen das Gesamturteil.
*
* @return {@code true} genau dann, wenn {@code kind == SPEC && severity == ERROR}
*/
public boolean isSpecError() {
return kind == FindingKind.SPEC && severity == Severity.ERROR;
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
/**
* Erzeugt einen neuen Builder für {@link Finding}.
*
* @param kind Befundart (Pflichtfeld)
* @param severity Schweregrad (Pflichtfeld)
* @param layer Schicht (Pflichtfeld)
* @param germanMessage Befundtext auf Deutsch (Pflichtfeld)
* @return neuer Builder
*/
public static Builder builder(FindingKind kind, Severity severity, FindingLayer layer,
String germanMessage) {
return new Builder(kind, severity, layer, germanMessage);
}
/**
* Builder für {@link Finding}. Alle optionalen Felder werden mit {@code null} initialisiert.
*/
public static final class Builder {
private final FindingKind kind;
private final Severity severity;
private final FindingLayer layer;
private final String germanMessage;
private String ruleId;
private String officialErrorCode;
private String segmentType;
private Integer segmentIndex;
private String fieldId;
private String rawValue;
private Integer position;
private String messageReference;
private Builder(FindingKind kind, Severity severity, FindingLayer layer,
String germanMessage) {
this.kind = kind;
this.severity = severity;
this.layer = layer;
this.germanMessage = germanMessage;
}
/** Setzt die interne Regel-ID. */
public Builder ruleId(String ruleId) {
this.ruleId = ruleId;
return this;
}
/** Setzt den offiziellen Spec-Fehlercode. */
public Builder officialErrorCode(String officialErrorCode) {
this.officialErrorCode = officialErrorCode;
return this;
}
/** Setzt den Segmenttyp (z.B. {@code "UNB"}). */
public Builder segmentType(String segmentType) {
this.segmentType = segmentType;
return this;
}
/** Setzt den Segmentindex. */
public Builder segmentIndex(Integer segmentIndex) {
this.segmentIndex = segmentIndex;
return this;
}
/** Setzt die Feld-ID (z.B. {@code "UNB_0020"}). */
public Builder fieldId(String fieldId) {
this.fieldId = fieldId;
return this;
}
/** Setzt den Rohwert. */
public Builder rawValue(String rawValue) {
this.rawValue = rawValue;
return this;
}
/** Setzt die Position (Byte-/Zeichenposition). */
public Builder position(Integer position) {
this.position = position;
return this;
}
/** Setzt die Nachrichtenreferenz (UNH 0062). */
public Builder messageReference(String messageReference) {
this.messageReference = messageReference;
return this;
}
/**
* Baut das {@link Finding}-Objekt.
*
* @return neuer, unveränderlicher {@link Finding}-Befund
*/
public Finding build() {
return new Finding(kind, severity, layer, ruleId, officialErrorCode,
segmentType, segmentIndex, fieldId, rawValue, position,
messageReference, germanMessage);
}
}
}
@@ -0,0 +1,25 @@
package de.gecheckt.asv.domain.finding;
/**
* Art eines Befunds: Spec-Urteil oder diagnostische Weiteranalyse.
*
* <p>Die Unterscheidung ist architektonisch zentral: Nur {@link #SPEC}-Befunde dürfen das
* Gesamturteil ({@link Verdict}) beeinflussen. {@link #DIAGNOSTIC}-Befunde liefern zusätzliche
* technische Informationen, ohne das Spec-Urteil zu verändern — auch nicht bei
* {@link Severity#ERROR}.</p>
*/
public enum FindingKind {
/**
* Befund ist Teil des normativen Spec-Urteils.
* Ein {@link Severity#ERROR}-Befund dieser Art setzt das Urteil auf {@link Verdict#INVALID}.
*/
SPEC,
/**
* Diagnostischer Befund zur Weiteranalyse.
* Dieser Befund beeinflusst das Spec-Urteil <em>niemals</em>, auch nicht bei
* {@link Severity#ERROR}.
*/
DIAGNOSTIC
}
@@ -0,0 +1,28 @@
package de.gecheckt.asv.domain.finding;
/**
* Schicht, auf die sich ein Befund bezieht.
*
* <p>Die Schichttrennung stellt sicher, dass technische Befunde nicht mit fachlichen Befunden
* vermischt werden und eine spätere GUI differenziert auf dieselben Daten zugreifen kann.</p>
*/
public enum FindingLayer {
/**
* Äußeres Artefakt — Datei auf Dateisystemebene (Dateiname, Dateityp,
* PKCS#7-/Auftragsdatei-/Nutzdatei-Schicht).
*/
ARTIFACT,
/**
* Technische Struktur — Service-Segmente (UNA, UNB, UNH, UNT, UNZ),
* KKS-Auftragssatz, Datei-/Transportebene, Nachrichtenhüllen.
*/
TECHNICAL_STRUCTURE,
/**
* Kanonisches Fachmodell — fachlich-technische Repräsentation der ASV-Nachrichten
* (ASVREC, ASVFEH und Storno-Ausprägungen).
*/
DOMAIN_MODEL
}
@@ -0,0 +1,20 @@
package de.gecheckt.asv.domain.finding;
/**
* Schweregrad eines Befunds.
*
* <p>Nur {@link #ERROR}-Befunde mit {@link FindingKind#SPEC} beeinflussen das Prüfurteil
* ({@link ValidationReport#computeVerdict()}). Warnungen und Hinweise verändern den
* Gültigkeitsstatus nicht.</p>
*/
public enum Severity {
/** Fehler — bei Spec-Befunden führt dies zu {@link Verdict#INVALID}. */
ERROR,
/** Warnung — beeinflusst das Spec-Urteil nicht. */
WARNING,
/** Hinweis — informativ, kein Einfluss auf das Spec-Urteil. */
HINT
}
@@ -0,0 +1,188 @@
package de.gecheckt.asv.domain.finding;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* Gesamtergebnis eines Validierungslaufs.
*
* <p>Ein {@code ValidationReport} fasst alle {@link Finding}-Befunde zusammen und berechnet
* daraus das Gesamturteil ({@link Verdict}). Die zentrale Invariante lautet:</p>
* <blockquote>
* {@link #computeVerdict()} berücksichtigt <em>ausschließlich</em> Befunde mit
* {@link FindingKind#SPEC} <em>und</em> {@link Severity#ERROR}.
* Ein {@link FindingKind#DIAGNOSTIC}-Befund mit {@link Severity#ERROR} setzt das Urteil
* <em>niemals</em> auf {@link Verdict#INVALID}.
* </blockquote>
*
* <p>Instanzen sind unveränderlich. Die Befundliste kann nach Erzeugung nicht mehr verändert
* werden.</p>
*/
public final class ValidationReport {
/** Name der validierten Eingabedatei. */
private final String fileName;
/** Zeitpunkt der Berichterstellung (UTC). */
private final Instant timestamp;
/**
* Unveränderliche Liste aller Befunde. Die Liste wird intern über
* {@link List#copyOf(java.util.Collection)} gesichert, sodass externe Referenzen
* sie nicht verändern können.
*/
private final List<Finding> findings;
/**
* Gibt an, ob es sich um einen Bedienfehler-Bericht handelt. In diesem Fall ist
* {@link #computeVerdict()} immer {@link Verdict#OPERATIONAL_ERROR}.
*/
private final boolean operationalError;
// ---------------------------------------------------------------------------
// Konstruktoren
// ---------------------------------------------------------------------------
/**
* Erzeugt einen normalen Validierungsbericht.
*
* @param fileName Dateiname der validierten Eingabedatei (nicht null)
* @param timestamp Zeitstempel der Berichterstellung (nicht null)
* @param findings Liste der Befunde (nicht null, darf leer sein)
*/
public ValidationReport(String fileName, Instant timestamp, List<Finding> findings) {
this.fileName = Objects.requireNonNull(fileName, "fileName darf nicht null sein");
this.timestamp = Objects.requireNonNull(timestamp, "timestamp darf nicht null sein");
Objects.requireNonNull(findings, "findings darf nicht null sein");
this.findings = List.copyOf(findings);
this.operationalError = false;
}
/**
* Privater Konstruktor für den Bedienfehler-Fall.
*/
private ValidationReport(String fileName, Instant timestamp, List<Finding> findings,
boolean operationalError) {
this.fileName = Objects.requireNonNull(fileName, "fileName darf nicht null sein");
this.timestamp = Objects.requireNonNull(timestamp, "timestamp darf nicht null sein");
Objects.requireNonNull(findings, "findings darf nicht null sein");
this.findings = List.copyOf(findings);
this.operationalError = operationalError;
}
// ---------------------------------------------------------------------------
// Factory-Methoden
// ---------------------------------------------------------------------------
/**
* Erzeugt einen Bedienfehler-Bericht. Das Urteil ist immer {@link Verdict#OPERATIONAL_ERROR}.
*
* <p>Typische Anwendungsfälle: fehlendes Pflichtargument, nicht lesbare Eingabedatei.</p>
*
* @param fileName Dateiname oder Platzhalter (nicht null)
* @param ruleId interne Regel-ID des auslösenden Prüfschritts (kann null sein)
* @param message deutschsprachige Fehlerbeschreibung (nicht null)
* @return neuer {@code ValidationReport} mit {@link Verdict#OPERATIONAL_ERROR}
*/
public static ValidationReport operationalError(String fileName, String ruleId,
String message) {
Objects.requireNonNull(fileName, "fileName darf nicht null sein");
Objects.requireNonNull(message, "message darf nicht null sein");
Finding errorFinding = Finding.builder(
FindingKind.SPEC, Severity.ERROR, FindingLayer.ARTIFACT, message)
.ruleId(ruleId)
.build();
return new ValidationReport(fileName, Instant.now(), List.of(errorFinding), true);
}
// ---------------------------------------------------------------------------
// Kern-Methoden
// ---------------------------------------------------------------------------
/**
* Berechnet das Gesamturteil des Validierungslaufs.
*
* <p><strong>Invariante:</strong> Nur Befunde mit {@link FindingKind#SPEC} und
* {@link Severity#ERROR} führen zu {@link Verdict#INVALID}. Diagnostische Befunde —
* auch solche mit {@link Severity#ERROR} — beeinflussen das Urteil niemals.</p>
*
* @return {@link Verdict#OPERATIONAL_ERROR} bei Bedienfehler-Bericht,
* {@link Verdict#INVALID} bei mindestens einem SPEC-ERROR-Befund,
* {@link Verdict#VALID} sonst
*/
public Verdict computeVerdict() {
if (operationalError) {
return Verdict.OPERATIONAL_ERROR;
}
return hasSpecErrors() ? Verdict.INVALID : Verdict.VALID;
}
/**
* Gibt zurück, ob mindestens ein SPEC-ERROR-Befund vorhanden ist.
*
* @return {@code true} genau dann, wenn {@code findings} mindestens einen Befund mit
* {@code kind == SPEC && severity == ERROR} enthält
*/
public boolean hasSpecErrors() {
return findings.stream().anyMatch(Finding::isSpecError);
}
/**
* Gibt alle Befunde mit {@link FindingKind#SPEC} zurück.
*
* @return unveränderliche Liste aller Spec-Befunde (niemals null)
*/
public List<Finding> specFindings() {
return findings.stream()
.filter(f -> f.kind() == FindingKind.SPEC)
.toList();
}
/**
* Gibt alle Befunde mit {@link FindingKind#DIAGNOSTIC} zurück.
*
* @return unveränderliche Liste aller Diagnose-Befunde (niemals null)
*/
public List<Finding> diagnosticFindings() {
return findings.stream()
.filter(f -> f.kind() == FindingKind.DIAGNOSTIC)
.toList();
}
// ---------------------------------------------------------------------------
// Getter
// ---------------------------------------------------------------------------
/**
* Gibt den Dateinamen der validierten Eingabedatei zurück.
*
* @return Dateiname (nicht null)
*/
public String getFileName() {
return fileName;
}
/**
* Gibt den Zeitstempel der Berichterstellung zurück.
*
* @return Zeitstempel (nicht null, UTC)
*/
public Instant getTimestamp() {
return timestamp;
}
/**
* Gibt die unveränderliche Liste aller Befunde zurück.
*
* <p>Die zurückgegebene Liste kann nicht verändert werden — Versuche werfen
* {@link UnsupportedOperationException}.</p>
*
* @return unveränderliche Befundliste (nicht null)
*/
public List<Finding> getFindings() {
return findings;
}
}
@@ -0,0 +1,30 @@
package de.gecheckt.asv.domain.finding;
/**
* Gesamturteil eines Validierungslaufs.
*
* <p>Das Urteil wird durch {@link ValidationReport#computeVerdict()} berechnet und basiert
* ausschließlich auf {@link FindingKind#SPEC}-Befunden mit {@link Severity#ERROR}.
* Diagnostische Befunde beeinflussen das Urteil niemals.</p>
*
* <p>Entsprechung zu Exit-Codes gemäß Architekturvorgabe:</p>
* <ul>
* <li>{@link #VALID} → Exit-Code 0</li>
* <li>{@link #INVALID} → Exit-Code 1</li>
* <li>{@link #OPERATIONAL_ERROR} → Exit-Code 2</li>
* </ul>
*/
public enum Verdict {
/** Gültig — keine SPEC-ERROR-Befunde vorhanden. Exit-Code 0. */
VALID,
/** Ungültig — mindestens ein SPEC-ERROR-Befund vorhanden. Exit-Code 1. */
INVALID,
/**
* Bedienfehler — z.B. fehlendes Argument oder nicht lesbare Eingabedatei.
* Exit-Code 2. Wird über {@link ValidationReport#operationalError} erzeugt.
*/
OPERATIONAL_ERROR
}
+9 -1
View File
@@ -1,10 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Log4j2-Konfiguration des ASV-Format-Validators.
HINWEIS (AP07): Der File-Appender hier ist ein FALLBACK-Default.
Er greift nur, wenn LoggingConfigurator.configureLogFile(Path) NICHT aufgerufen wurde
(z.B. in Unit-Tests). Bei produktiven Läufen wird der Dateipfad programmatisch gesetzt.
-->
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_ERR">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
<File name="File" fileName="logs/asv-format-validator.log" append="true">
<!-- Fallback: greift nur wenn configureLogFile(Path) nicht aufgerufen wurde -->
<File name="File" fileName="logs/asv-format-validator-fallback.log" append="true">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>
</File>
</Appenders>
@@ -0,0 +1,93 @@
package de.gecheckt.asv;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/**
* Automatisierte Architekturtests für den ASV-Format-Validator.
*
* <p>Sichert die in M1 etablierten Strukturregeln der hexagonalen Architektur dauerhaft ab.
* Jede Verletzung führt zu einem fehlschlagenden Build.</p>
*
* <p>Regeln:</p>
* <ul>
* <li>A Log4j2-Typen dürfen nur im Logging-Adapter und im Bootstrap sichtbar sein</li>
* <li>B Domain-Klassen dürfen keine Adapter- oder Bootstrap-Abhängigkeiten haben</li>
* <li>C Application-Klassen dürfen keine Adapter- oder Bootstrap-Abhängigkeiten haben</li>
* <li>D Preview-Validatoren werden in M1 nicht aus aktivem Adapter- oder Bootstrap-Code referenziert</li>
* </ul>
*/
@AnalyzeClasses(packages = "de.gecheckt.asv", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
/**
* Regel A — Log4j2-Sichtbarkeit.
*
* Log4j2-Typen ({@code org.apache.logging.log4j.*}) dürfen nur im Logging-Adapter
* ({@code adapter.out.logging}) und im Bootstrap sichtbar sein. Alle anderen Pakete
* verwenden ausschließlich die SLF4J-Fassade.
*/
@ArchTest
static final ArchRule log4j2_nur_in_logging_adapter_und_bootstrap =
noClasses()
.that().resideOutsideOfPackages(
"de.gecheckt.asv.adapter.out.logging..",
"de.gecheckt.asv.bootstrap..")
.should().dependOnClassesThat()
.resideInAPackage("org.apache.logging.log4j..")
.because("Log4j2 darf nur im Logging-Adapter und im Bootstrap sichtbar sein.");
/**
* Regel B — Domain-Reinheit.
*
* Domain-Klassen kennen keine Adapter-Implementierungen und kein Bootstrap.
* Nur die Domain selbst sowie die SLF4J-API und Java-Standardklassen dürfen
* aus dem Domain-Paket referenziert werden.
*/
@ArchTest
static final ArchRule domain_hat_keine_adapter_abhaengigkeit =
noClasses()
.that().resideInAPackage("de.gecheckt.asv.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"de.gecheckt.asv.adapter..",
"de.gecheckt.asv.bootstrap..");
/**
* Regel C — Application-Reinheit.
*
* Application-Klassen (Services, Ports) kennen keine Adapter-Implementierungen
* und kein Bootstrap. Abhängigkeiten gehen nur in Richtung Domain.
*/
@ArchTest
static final ArchRule application_kennt_keine_adapter_implementierungen =
noClasses()
.that().resideInAPackage("de.gecheckt.asv.application..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"de.gecheckt.asv.adapter..",
"de.gecheckt.asv.bootstrap..");
/**
* Regel D — Preview-Isolation.
*
* Die in M1 eingefrorenen Preview-Validatoren ({@code DefaultStructureValidator} und
* {@code DefaultFieldValidator}) dürfen aus aktivem Adapter- und Bootstrap-Code nicht
* direkt referenziert werden. Sie werden erst ab M3 wieder aktiv eingesetzt.
*/
@ArchTest
static final ArchRule preview_wird_nicht_aus_aktivem_code_referenziert =
noClasses()
.that().resideInAnyPackage(
"de.gecheckt.asv.adapter..",
"de.gecheckt.asv.bootstrap..")
.should().dependOnClassesThat()
.haveSimpleNameContaining("DefaultStructureValidator")
.orShould().dependOnClassesThat()
.haveSimpleNameContaining("DefaultFieldValidator")
.because("Preview-Validatoren sind in M1 eingefroren und werden erst ab M3 aktiv verwendet.");
}
@@ -1,219 +0,0 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.domain.model.Field;
import de.gecheckt.asv.domain.model.InputFile;
import de.gecheckt.asv.domain.model.Message;
import de.gecheckt.asv.domain.model.Segment;
import de.gecheckt.asv.adapter.out.parsing.InputFileParseException;
import de.gecheckt.asv.adapter.out.parsing.InputFileParser;
import de.gecheckt.asv.application.InputFileValidator;
import de.gecheckt.asv.application.model.ValidationError;
import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.application.model.ValidationSeverity;
import de.gecheckt.asv.adapter.out.reporting.ValidationResultPrinter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
/**
* Zusätzliche Unittests für AsvValidatorApplication.
*/
class AsvValidatorApplicationAdditionalTest {
@TempDir
Path tempDir;
@Test
void testRunWithValidFileShouldReturnSuccessExitCode() throws InputFileParseException, IOException {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
// Create a test file
Path testFile = tempDir.resolve("valid-file.txt");
String validContent = "HDR|TestHeader\n" +
"DTL|TestData|MoreData\n" +
"TRL|3";
Files.writeString(testFile, validContent);
String[] args = {testFile.toString()};
// Create real objects instead of mocks for final classes
Field hdrField1 = new Field(1, "HDR");
Field hdrField2 = new Field(2, "TestHeader");
Segment hdrSegment = new Segment("HDR", 1, List.of(hdrField1, hdrField2));
Field dtlField1 = new Field(1, "DTL");
Field dtlField2 = new Field(2, "TestData");
Field dtlField3 = new Field(3, "MoreData");
Segment dtlSegment = new Segment("DTL", 2, List.of(dtlField1, dtlField2, dtlField3));
Field trlField1 = new Field(1, "TRL");
Field trlField2 = new Field(2, "3");
Segment trlSegment = new Segment("TRL", 3, List.of(trlField1, trlField2));
Message message = new Message(1, List.of(hdrSegment, dtlSegment, trlSegment));
InputFile inputFile = new InputFile("valid-file.txt", List.of(message));
// Mock the parser and validator behavior
when(parser.parse(anyString(), anyString())).thenReturn(inputFile);
// Create a real ValidationResult with no errors
ValidationResult validationResult = new ValidationResult(List.of());
when(validator.validate(inputFile)).thenReturn(validationResult);
// Capture System.out
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
PrintStream originalOut = System.out;
System.setOut(new PrintStream(outContent));
try {
// When
int exitCode = app.run(args);
// Then
assertEquals(0, exitCode);
// Verify that the printer was called
verify(printer).printToConsole(validationResult);
} finally {
// Restore System.out
System.setOut(originalOut);
}
}
@Test
void testRunWithInvalidFileShouldReturnValidationErrorsExitCode() throws InputFileParseException, IOException {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
// Create an invalid test file (missing required segments)
Path testFile = tempDir.resolve("invalid-file.txt");
String invalidContent = "DTL|TestData|MoreData\n" + // Missing HDR
"DTL|MoreData|EvenMoreData\n"; // Missing TRL
Files.writeString(testFile, invalidContent);
String[] args = {testFile.toString()};
// Create real objects instead of mocks for final classes
Field dtlField1 = new Field(1, "DTL");
Field dtlField2 = new Field(2, "TestData");
Field dtlField3 = new Field(3, "MoreData");
Segment dtlSegment1 = new Segment("DTL", 1, List.of(dtlField1, dtlField2, dtlField3));
Field dtlField4 = new Field(1, "DTL");
Field dtlField5 = new Field(2, "MoreData");
Field dtlField6 = new Field(3, "EvenMoreData");
Segment dtlSegment2 = new Segment("DTL", 2, List.of(dtlField4, dtlField5, dtlField6));
Message message = new Message(1, List.of(dtlSegment1, dtlSegment2));
InputFile inputFile = new InputFile("invalid-file.txt", List.of(message));
// Mock the parser and validator behavior
when(parser.parse(anyString(), anyString())).thenReturn(inputFile);
// Create a real ValidationResult with errors
ValidationError error = new ValidationError(
"MISSING_SEGMENT",
"Required segment HDR is missing",
ValidationSeverity.ERROR,
"HDR",
1,
"HDR",
1,
null,
"HDR segment is required"
);
ValidationResult validationResult = new ValidationResult(List.of(error));
when(validator.validate(inputFile)).thenReturn(validationResult);
// Capture System.out
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
PrintStream originalOut = System.out;
System.setOut(new PrintStream(outContent));
try {
// When
int exitCode = app.run(args);
// Then
assertEquals(3, exitCode); // Validation errors exit code
// Verify that the printer was called
verify(printer).printToConsole(validationResult);
} finally {
// Restore System.out
System.setOut(originalOut);
}
}
/**
* Spezialisierter Test für den Fall, dass ein technisch lesbares/parstabares Dokument
* Validierungsfehler enthält und der CLI Exit-Code 3 zurückgibt.
*
* Dieser Test konzentriert sich explizit auf:
* 1. Parser liefert ein InputFile
* 2. Validator liefert ein ValidationResult mit mindestens einem ERROR
* 3. CLI gibt daraufhin Exit-Code 3 zurück
*/
@Test
void testParserReturnsInputFileAndValidatorReturnsErrorsShouldReturnExitCodeThree() throws InputFileParseException, IOException {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
// Create a dummy test file (content doesn't matter since we're mocking the parser)
Path testFile = tempDir.resolve("dummy-file.txt");
Files.writeString(testFile, "dummy content");
String[] args = {testFile.toString()};
// Mock: Parser liefert ein gültiges InputFile
InputFile inputFile = mock(InputFile.class);
when(parser.parse(anyString(), anyString())).thenReturn(inputFile);
// Mock: Validator liefert ein ValidationResult mit mindestens einem ERROR
ValidationError validationError = new ValidationError(
"TEST_ERROR_CODE",
"Test error message",
ValidationSeverity.ERROR, // Wichtig: ValidationSeverity.ERROR
"TEST_SEGMENT",
1,
"TEST_FIELD",
1,
null,
"Test error description"
);
ValidationResult validationResultWithErrors = new ValidationResult(List.of(validationError));
when(validator.validate(inputFile)).thenReturn(validationResultWithErrors);
// When
int exitCode = app.run(args);
// Then
// Prüfe explizit, dass der Exit-Code 3 ist
assertEquals(3, exitCode, "CLI should return exit code 3 when validation errors occur");
// Verify that the printer was called with the validation result
verify(printer).printToConsole(validationResultWithErrors);
}
}
@@ -1,104 +0,0 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.adapter.out.parsing.InputFileParser;
import de.gecheckt.asv.application.InputFileValidator;
import de.gecheckt.asv.adapter.out.reporting.ValidationResultPrinter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
/**
* Unittests für AsvValidatorApplication.
*/
class AsvValidatorApplicationTest {
@TempDir
Path tempDir;
@Test
void testRunWithNoArgumentsShouldPrintUsageAndReturnInvalidArgumentsExitCode() {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
String[] args = {};
// Capture System.out
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
PrintStream originalOut = System.out;
System.setOut(new PrintStream(outContent));
try {
// When
int exitCode = app.run(args);
// Then
assertEquals(1, exitCode);
assertEquals(true, outContent.toString().contains("Verwendung:"), "Output should contain usage information");
} finally {
// Restore System.out
System.setOut(originalOut);
}
}
@Test
void testRunWithTooManyArgumentsShouldPrintUsageAndReturnInvalidArgumentsExitCode() {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
String[] args = {"file1.txt", "file2.txt"};
// Capture System.out
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
PrintStream originalOut = System.out;
System.setOut(new PrintStream(outContent));
try {
// When
int exitCode = app.run(args);
// Then
assertEquals(1, exitCode);
assertEquals(true, outContent.toString().contains("Verwendung:"), "Output should contain usage information");
} finally {
// Restore System.out
System.setOut(originalOut);
}
}
@Test
void testRunWithNonExistentFileShouldReturnFileErrorExitCode() {
// Given
InputFileParser parser = mock(InputFileParser.class);
InputFileValidator validator = mock(InputFileValidator.class);
ValidationResultPrinter printer = mock(ValidationResultPrinter.class);
AsvValidatorApplication app = new AsvValidatorApplication(parser, validator, printer);
String[] args = {"/non/existent/file.txt"};
// Capture System.err
ByteArrayOutputStream errContent = new ByteArrayOutputStream();
PrintStream originalErr = System.err;
System.setErr(new PrintStream(errContent));
try {
// When
int exitCode = app.run(args);
// Then
assertEquals(2, exitCode);
assertEquals(true, errContent.toString().contains("File does not exist"), "Error output should contain file not found message");
} finally {
// Restore System.err
System.setErr(originalErr);
}
}
}
@@ -0,0 +1,403 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.adapter.out.logging.LoggingConfigurator;
import de.gecheckt.asv.adapter.out.reporting.ReportFileWriter;
import de.gecheckt.asv.application.FileValidationService;
import de.gecheckt.asv.domain.finding.Finding;
import de.gecheckt.asv.domain.finding.ValidationReport;
import de.gecheckt.asv.domain.finding.Verdict;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Unit-Tests für die Bedienfehler-Behandlung (Exit-Code 2, Minimalbericht) in {@link CliRunner}.
*
* <p>Abgedeckte Abnahmekriterien aus AP08:</p>
* <ul>
* <li>Fall 1: Kein Argument → Exit 2, nur Konsole (STDERR), kein Verzeichnis</li>
* <li>Fall 2: Mehr als ein Argument → Exit 2, nur Konsole</li>
* <li>Fall 3: Datei existiert nicht → Exit 2, Berichtdatei im übergeordneten Verzeichnis</li>
* <li>Fall 4: Pfad ist kein regulärer Dateityp → Exit 2, nur Konsole</li>
* <li>Fall 5: Datei nicht lesbar → Exit 2, Berichtdatei im übergeordneten Verzeichnis</li>
* <li>Verdict OPERATIONAL_ERROR wird korrekt gesetzt</li>
* <li>Kein Stack-Trace in STDERR</li>
* </ul>
*
* <p>Hinweis: Der {@link LoggingConfigurator} wird in allen Tests als No-Op-Mock eingesetzt,
* um Windows-seitiges Dateisperr-Verhalten durch geöffnete Log4j2-Appender zu vermeiden.</p>
*/
class CliRunnerOperationalErrorTest {
@TempDir
Path tempDir;
private PrintStream originalStderr;
private ByteArrayOutputStream stderrBuf;
private PrintStream originalStdout;
private ByteArrayOutputStream stdoutBuf;
@BeforeEach
void captureStreams() {
originalStderr = System.err;
stderrBuf = new ByteArrayOutputStream();
System.setErr(new PrintStream(stderrBuf, true, StandardCharsets.UTF_8));
originalStdout = System.out;
stdoutBuf = new ByteArrayOutputStream();
System.setOut(new PrintStream(stdoutBuf, true, StandardCharsets.UTF_8));
}
@AfterEach
void restoreStreams() {
System.setErr(originalStderr);
System.setOut(originalStdout);
}
/** Gibt den bisher auf STDERR geschriebenen Text zurück. */
private String stderr() {
return stderrBuf.toString(StandardCharsets.UTF_8);
}
/** Gibt den bisher auf STDOUT geschriebenen Text zurück. */
private String stdout() {
return stdoutBuf.toString(StandardCharsets.UTF_8);
}
/**
* Erzeugt einen {@link CliRunner} mit echten Adaptern und No-Op-LoggingConfigurator.
*
* @param service der zu verwendende {@link FileValidationService}
*/
private CliRunner runnerWith(FileValidationService service) {
LoggingConfigurator noOpLogging = mock(LoggingConfigurator.class);
doNothing().when(noOpLogging).configureLogFile(any(Path.class));
SuffixResolver sr = new SuffixResolver();
return new CliRunner(service, noOpLogging, sr, new ReportFileWriter(sr));
}
// -----------------------------------------------------------------------
// Fall 1: Kein Argument
// -----------------------------------------------------------------------
@Test
@DisplayName("Fall 1: Kein Argument → Exit 2, ruleId OPERATIONAL-MISSING-ARG")
void fall1_keinArgument_exitCode2_undRuleId() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode,
"Kein Argument muss Exit-Code 2 liefern");
verifyNoInteractions(service);
}
@Test
@DisplayName("Fall 1: Kein Argument → STDERR enthält Fehlermeldung, keine Berichtdatei")
void fall1_keinArgument_nurKonsole() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{});
String err = stderr();
assertFalse(err.isBlank(), "STDERR muss eine Fehlermeldung enthalten");
// STDOUT (normale Berichtdatei) bleibt leer — nur STDERR
assertTrue(stdout().isBlank(), "STDOUT darf bei kein-Argument-Fehler keine Ausgabe enthalten");
// Keine Berichtdatei im TempDir
long txtFiles = countTxtFiles(tempDir);
assertEquals(0, txtFiles, "Bei 'kein Argument' darf keine Berichtdatei entstehen");
}
@Test
@DisplayName("Fall 1: Kein Argument → Minimalbericht im STDERR enthält BEDIENFEHLER")
void fall1_keinArgument_minimalberichtEnthaeltBedienfehler() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{});
assertTrue(stderr().contains("BEDIENFEHLER"),
"Der Minimalbericht muss das Urteil BEDIENFEHLER enthalten");
}
// -----------------------------------------------------------------------
// Fall 2: Mehr als ein Argument
// -----------------------------------------------------------------------
@Test
@DisplayName("Fall 2: Mehr als ein Argument → Exit 2, ruleId OPERATIONAL-TOO-MANY-ARGS")
void fall2_zuVieleArgumente_exitCode2() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{"datei1.auf", "datei2.auf"});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode,
"Zu viele Argumente müssen Exit-Code 2 liefern");
verifyNoInteractions(service);
}
@Test
@DisplayName("Fall 2: Mehr als ein Argument → nur STDERR, keine Datei")
void fall2_zuVieleArgumente_nurKonsole() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{"datei1.auf", "datei2.auf", "datei3.auf"});
assertFalse(stderr().isBlank(), "STDERR muss Fehlermeldung enthalten");
assertTrue(stdout().isBlank(), "STDOUT darf keine Ausgabe enthalten");
assertEquals(0, countTxtFiles(tempDir), "Keine Berichtdatei bei zu vielen Argumenten");
}
// -----------------------------------------------------------------------
// Fall 3: Datei existiert nicht
// -----------------------------------------------------------------------
@Test
@DisplayName("Fall 3: Datei existiert nicht → Exit 2, ruleId OPERATIONAL-FILE-NOT-FOUND")
void fall3_dateiExistiertNicht_exitCode2() {
Path nichtVorhanden = tempDir.resolve("nicht-vorhanden.auf");
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{nichtVorhanden.toString()});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode,
"Nicht vorhandene Datei muss Exit-Code 2 liefern");
verifyNoInteractions(service);
}
@Test
@DisplayName("Fall 3: Datei existiert nicht → Berichtdatei im übergeordneten Verzeichnis")
void fall3_dateiExistiertNicht_berichtdateiImUebergeordnetenVerzeichnis() {
Path nichtVorhanden = tempDir.resolve("nicht-vorhanden.auf");
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{nichtVorhanden.toString()});
// Im tempDir (übergeordnetes Verzeichnis der nicht-vorhandenen Datei) soll eine .txt entstehen
Path erwarteterBericht = tempDir.resolve("nicht-vorhanden.auf.txt");
assertTrue(Files.exists(erwarteterBericht),
"Berichtdatei soll im übergeordneten Verzeichnis liegen: " + erwarteterBericht);
}
@Test
@DisplayName("Fall 3: Datei existiert nicht → Berichtdatei enthält BEDIENFEHLER-Urteil")
void fall3_dateiExistiertNicht_berichtdateiEnthaeltOpertionalError() throws IOException {
Path nichtVorhanden = tempDir.resolve("fehlt.auf");
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{nichtVorhanden.toString()});
Path bericht = tempDir.resolve("fehlt.auf.txt");
assertTrue(Files.exists(bericht), "Berichtdatei muss existieren");
String inhalt = Files.readString(bericht, StandardCharsets.UTF_8);
assertTrue(inhalt.contains("BEDIENFEHLER"),
"Bericht muss BEDIENFEHLER-Urteil enthalten");
assertTrue(inhalt.contains("OPERATIONAL-FILE-NOT-FOUND"),
"Bericht muss ruleId OPERATIONAL-FILE-NOT-FOUND enthalten");
}
// -----------------------------------------------------------------------
// Fall 4: Pfad ist kein regulärer Dateityp (Verzeichnis)
// -----------------------------------------------------------------------
@Test
@DisplayName("Fall 4: Pfad ist ein Verzeichnis → Exit 2, ruleId OPERATIONAL-NOT-REGULAR")
void fall4_pfadIstVerzeichnis_exitCode2() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
// tempDir selbst ist ein Verzeichnis
int exitCode = runner.run(new String[]{tempDir.toString()});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode,
"Verzeichnis als Eingabe muss Exit-Code 2 liefern");
verifyNoInteractions(service);
}
@Test
@DisplayName("Fall 4: Pfad ist ein Verzeichnis → nur STDERR, keine Berichtdatei")
void fall4_pfadIstVerzeichnis_nurKonsole() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{tempDir.toString()});
assertFalse(stderr().isBlank(), "STDERR muss Fehlermeldung enthalten");
assertTrue(stdout().isBlank(), "STDOUT darf keine Ausgabe enthalten");
}
// -----------------------------------------------------------------------
// Fall 5: Datei nicht lesbar — nur auf Nicht-Windows testbar
// Hinweis: Auf Windows gibt es keine zuverlässige Möglichkeit, eine Datei
// per setReadable(false) für den eigenen Prozess unlesbar zu machen.
// Dieser Test wird daher nur auf Unix-ähnlichen Systemen ausgeführt.
// -----------------------------------------------------------------------
@Test
@DisplayName("Fall 5: Datei nicht lesbar → Exit 2, ruleId OPERATIONAL-NOT-READABLE")
void fall5_dateiNichtLesbar_exitCode2() throws IOException {
// Nur auf Unix-ähnlichen Systemen ausführen (Windows ignoriert setReadable)
org.junit.jupiter.api.Assumptions.assumeTrue(
!System.getProperty("os.name", "").toLowerCase().contains("windows"),
"Test wird auf Windows übersprungen (setReadable nicht zuverlässig)");
Path nichtLesbar = tempDir.resolve("gesperrt.auf");
Files.writeString(nichtLesbar, "Inhalt");
boolean ok = nichtLesbar.toFile().setReadable(false);
org.junit.jupiter.api.Assumptions.assumeTrue(ok,
"setReadable(false) nicht anwendbar — Test wird übersprungen");
try {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{nichtLesbar.toString()});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode,
"Nicht lesbare Datei muss Exit-Code 2 liefern");
verifyNoInteractions(service);
} finally {
nichtLesbar.toFile().setReadable(true);
}
}
// -----------------------------------------------------------------------
// Verdict OPERATIONAL_ERROR verifizieren
// -----------------------------------------------------------------------
@Test
@DisplayName("operationalError-Report: Verdict ist OPERATIONAL_ERROR")
void operationalErrorReport_verdictIstOPERATIONAL_ERROR() {
ValidationReport report = ValidationReport.operationalError(
"<kein Argument>", "OPERATIONAL-MISSING-ARG",
"Kein Dateipfad angegeben.");
assertEquals(Verdict.OPERATIONAL_ERROR, report.computeVerdict(),
"operationalError-Factory muss Verdict OPERATIONAL_ERROR liefern");
List<Finding> findings = report.getFindings();
assertFalse(findings.isEmpty(), "Mindestens ein Finding erwartet");
Finding finding = findings.get(0);
assertEquals("OPERATIONAL-MISSING-ARG", finding.ruleId(),
"ruleId muss OPERATIONAL-MISSING-ARG sein");
assertNotNull(finding.germanMessage(), "germanMessage darf nicht null sein");
}
@Test
@DisplayName("Alle ruleIds der 5 Bedienfehler-Fälle sind korrekt definiert")
void alleRuleIds_sindKorrektDefiniert() {
String[] ruleIds = {
"OPERATIONAL-MISSING-ARG",
"OPERATIONAL-TOO-MANY-ARGS",
"OPERATIONAL-FILE-NOT-FOUND",
"OPERATIONAL-NOT-REGULAR",
"OPERATIONAL-NOT-READABLE"
};
for (String ruleId : ruleIds) {
ValidationReport report = ValidationReport.operationalError(
"test.auf", ruleId, "Testmeldung für " + ruleId);
assertEquals(Verdict.OPERATIONAL_ERROR, report.computeVerdict(),
"Report mit ruleId " + ruleId + " muss OPERATIONAL_ERROR liefern");
assertEquals(ruleId, report.getFindings().get(0).ruleId(),
"ruleId muss korrekt gesetzt sein: " + ruleId);
}
}
// -----------------------------------------------------------------------
// Negativ-Test: Kein Stack-Trace in STDERR
// -----------------------------------------------------------------------
@Test
@DisplayName("Kein Stack-Trace in STDERR bei Bedienfehler 'Kein Argument'")
void keinArgument_keinStackTraceInStderr() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{});
String err = stderr();
// Stack-Traces enthalten "at " gefolgt von Java-Paketnamen
assertFalse(err.contains("\tat "),
"STDERR darf keinen Stack-Trace enthalten (kein '\\tat '). Gefunden: " + err);
assertFalse(err.contains("Exception"),
"STDERR darf keinen Exception-Klassennamen enthalten. Gefunden: " + err);
}
@Test
@DisplayName("Kein Stack-Trace in STDERR bei Bedienfehler 'Datei nicht gefunden'")
void dateiNichtGefunden_keinStackTraceInStderr() {
Path nichtVorhanden = tempDir.resolve("nicht-da.auf");
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{nichtVorhanden.toString()});
String err = stderr();
assertFalse(err.contains("\tat "),
"STDERR darf keinen Stack-Trace enthalten. Gefunden: " + err);
assertFalse(err.contains("Exception"),
"STDERR darf keinen Exception-Klassennamen enthalten. Gefunden: " + err);
}
@Test
@DisplayName("Kein Stack-Trace in STDERR bei Bedienfehler 'Pfad ist Verzeichnis'")
void pfadIstVerzeichnis_keinStackTraceInStderr() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
runner.run(new String[]{tempDir.toString()});
String err = stderr();
assertFalse(err.contains("\tat "),
"STDERR darf keinen Stack-Trace enthalten. Gefunden: " + err);
assertFalse(err.contains("Exception"),
"STDERR darf keinen Exception-Klassennamen enthalten. Gefunden: " + err);
}
// -----------------------------------------------------------------------
// Hilfsmethoden
// -----------------------------------------------------------------------
/** Zählt .txt-Dateien direkt im angegebenen Verzeichnis. */
private long countTxtFiles(Path dir) {
try {
return Files.list(dir)
.filter(p -> p.toString().endsWith(".txt"))
.count();
} catch (IOException e) {
return 0;
}
}
}
@@ -0,0 +1,195 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.adapter.out.logging.LoggingConfigurator;
import de.gecheckt.asv.adapter.out.reporting.ReportFileWriter;
import de.gecheckt.asv.application.DummyFileValidationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
/**
* End-to-End-Integrationstests für die Ausgabeartefakte (AP07).
*
* <p>Prüft, dass nach einem Lauf beide Ausgabedateien (Berichtdatei {@code .txt}
* und Log-Datei {@code .log}) im Verzeichnis der Eingabedatei entstehen, dass die
* Suffix-Logik bei Folgeläufen greift und dass beide Dateien UTF-8 kodiert sind.</p>
*
* <p>Hinweis: Der {@link LoggingConfigurator} wird als No-Op-Mock eingesetzt, um das
* TempDir-Locking durch geöffnete Log4j2-FileAppender auf Windows zu vermeiden.
* Ein separater manueller End-to-End-Test mit dem Uber-JAR belegt, dass die echte
* Log-Datei-Umkonfiguration funktioniert.</p>
*/
class CliRunnerOutputArtifactsTest {
/**
* Erzeugt einen CliRunner mit DummyFileValidationService und No-Op-LoggingConfigurator.
*/
private CliRunner buildRunner() {
LoggingConfigurator noOpLogging = mock(LoggingConfigurator.class);
doNothing().when(noOpLogging).configureLogFile(any(Path.class));
SuffixResolver suffixResolver = new SuffixResolver();
ReportFileWriter reportFileWriter = new ReportFileWriter(suffixResolver);
return new CliRunner(
new DummyFileValidationService(),
noOpLogging,
suffixResolver,
reportFileWriter);
}
@Test
@DisplayName("Lauf 1: foo.auf → foo.auf.txt wird erzeugt")
void lauf1_erzeugtBerichtdateiOhneSuffix(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
suppressStdout();
try {
buildRunner().run(new String[]{inputFile.toString()});
} finally {
restoreStdout();
}
Path expectedReport = tempDir.resolve("foo.auf.txt");
assertTrue(Files.exists(expectedReport),
"Nach Lauf 1 soll foo.auf.txt existieren");
}
@Test
@DisplayName("Lauf 2: foo.auf → foo.auf_v1.txt bei vorhandener foo.auf.txt")
void lauf2_erzeugtBerichtdateiMitV1Suffix(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
suppressStdout();
try {
// Lauf 1
buildRunner().run(new String[]{inputFile.toString()});
// Lauf 2
buildRunner().run(new String[]{inputFile.toString()});
} finally {
restoreStdout();
}
assertTrue(Files.exists(tempDir.resolve("foo.auf.txt")),
"Lauf 1 soll foo.auf.txt erzeugt haben");
assertTrue(Files.exists(tempDir.resolve("foo.auf_v1.txt")),
"Lauf 2 soll foo.auf_v1.txt erzeugt haben");
}
@Test
@DisplayName("Lauf 3: foo.auf → foo.auf_v2.txt bei vorhandenen foo.auf.txt und foo.auf_v1.txt")
void lauf3_erzeugtBerichtdateiMitV2Suffix(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
suppressStdout();
try {
buildRunner().run(new String[]{inputFile.toString()});
buildRunner().run(new String[]{inputFile.toString()});
buildRunner().run(new String[]{inputFile.toString()});
} finally {
restoreStdout();
}
assertTrue(Files.exists(tempDir.resolve("foo.auf_v2.txt")),
"Lauf 3 soll foo.auf_v2.txt erzeugt haben");
}
@Test
@DisplayName("Suffix-Zählung für .txt und .log ist unabhängig")
void suffixZaehlung_istProExtensionUnabhaengig(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("bar.auf");
Files.createFile(inputFile);
// .txt voranlegen (simuliert bereits vorhandenen Bericht ohne Log)
Files.createFile(tempDir.resolve("bar.auf.txt"));
suppressStdout();
try {
buildRunner().run(new String[]{inputFile.toString()});
} finally {
restoreStdout();
}
// .txt bereits vorhanden → _v1.txt erzeugt
assertTrue(Files.exists(tempDir.resolve("bar.auf_v1.txt")),
"Vorhandene bar.auf.txt soll zu bar.auf_v1.txt führen");
// .log war nicht vorhanden → kein Suffix (wird vom No-Op-Logger nicht erzeugt;
// der SuffixResolver würde bar.auf.log zurückgeben, wenn configureLogFile aufgerufen wird)
}
@Test
@DisplayName("Berichtdatei ist in UTF-8 kodiert (enthält Umlaute korrekt)")
void berichtdatei_istInUtf8(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("encoding.auf");
Files.createFile(inputFile);
suppressStdout();
try {
buildRunner().run(new String[]{inputFile.toString()});
} finally {
restoreStdout();
}
Path reportFile = tempDir.resolve("encoding.auf.txt");
assertTrue(Files.exists(reportFile));
byte[] bytes = Files.readAllBytes(reportFile);
String content = new String(bytes, StandardCharsets.UTF_8);
// Das Urteil "GÜLTIG" enthält das Umlaut Ü — wenn UTF-8 korrekt, dann lesbar
assertTrue(content.contains("GÜLTIG"),
"UTF-8-Datei soll 'GÜLTIG' mit Umlaut enthalten");
}
@Test
@DisplayName("Konsolenausgabe ist identisch zum Berichtdatei-Inhalt")
void konsolenausgabe_identischZumBerichtinhalt(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("console.auf");
Files.createFile(inputFile);
PrintStream originalOut = System.out;
java.io.ByteArrayOutputStream outBuf = new java.io.ByteArrayOutputStream();
System.setOut(new PrintStream(outBuf, true, StandardCharsets.UTF_8));
try {
buildRunner().run(new String[]{inputFile.toString()});
} finally {
System.setOut(originalOut);
}
Path reportFile = tempDir.resolve("console.auf.txt");
String fileContent = Files.readString(reportFile, StandardCharsets.UTF_8);
String consoleContent = outBuf.toString(StandardCharsets.UTF_8);
assertEquals(fileContent, consoleContent,
"Konsolenausgabe soll identisch zum Berichtdatei-Inhalt sein");
}
// -----------------------------------------------------------------------
// Hilfsmethoden für stdout-Unterdrückung
// -----------------------------------------------------------------------
private PrintStream originalOut;
private void suppressStdout() {
originalOut = System.out;
System.setOut(new PrintStream(new java.io.ByteArrayOutputStream()));
}
private void restoreStdout() {
System.setOut(originalOut);
}
}
@@ -0,0 +1,248 @@
package de.gecheckt.asv.adapter.in.cli;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.adapter.out.logging.LoggingConfigurator;
import de.gecheckt.asv.adapter.out.reporting.ReportFileWriter;
import de.gecheckt.asv.application.FileValidationService;
import de.gecheckt.asv.domain.finding.Finding;
import de.gecheckt.asv.domain.finding.FindingKind;
import de.gecheckt.asv.domain.finding.FindingLayer;
import de.gecheckt.asv.domain.finding.Severity;
import de.gecheckt.asv.domain.finding.ValidationReport;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Unit-Tests für {@link CliRunner}.
*
* <p>Abgedeckte Abnahmekriterien aus AP06/AP07:</p>
* <ul>
* <li>Aufruf ohne Argument → Exit-Code 2</li>
* <li>Aufruf mit ≥ 2 Argumenten → Exit-Code 2</li>
* <li>Aufruf mit nicht existierender Datei → Exit-Code 2</li>
* <li>Aufruf mit leerer, lesbarer Datei → Exit-Code 0</li>
* <li>Konsolenausgabe enthält Berichtinhalt</li>
* <li>Berichtdatei wird im Verzeichnis der Eingabedatei erzeugt</li>
* </ul>
*
* <p>Hinweis: Der {@link LoggingConfigurator} wird in diesen Tests als Mockito-Mock
* verwendet (No-Op für {@code configureLogFile}), um zu verhindern, dass ein echter
* Log4j2-File-Appender im TempDir geöffnet bleibt und die TempDir-Bereinigung durch
* JUnit blockiert (Windows-spezifisches Dateisperrproblem).</p>
*/
class CliRunnerTest {
@TempDir
Path tempDir;
// -----------------------------------------------------------------------
// Hilfsmethode: CliRunner mit No-Op-LoggingConfigurator erzeugen
// -----------------------------------------------------------------------
/**
* Erzeugt einen {@link CliRunner} mit echten Adaptern (SuffixResolver, ReportFileWriter)
* und einem Mockito-Mock für LoggingConfigurator (No-Op für configureLogFile).
*/
private CliRunner runnerWith(FileValidationService service) {
LoggingConfigurator noOpLogging = mock(LoggingConfigurator.class);
doNothing().when(noOpLogging).configureLogFile(any(Path.class));
return new CliRunner(
service,
noOpLogging,
new SuffixResolver(),
new ReportFileWriter(new SuffixResolver()));
}
// -----------------------------------------------------------------------
// Argument-Validierung
// -----------------------------------------------------------------------
@Test
@DisplayName("Kein Argument → Exit-Code 2 (OPERATIONAL_ERROR)")
void keineArgumente_liefernExitCode2() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
ByteArrayOutputStream err = captureStderr();
int exitCode = runner.run(new String[]{});
restoreStderr();
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode);
assertTrue(err.toString().contains("Fehler"),
"STDERR soll eine deutsche Fehlermeldung enthalten");
verifyNoInteractions(service);
}
@Test
@DisplayName("Zwei Argumente → Exit-Code 2 (OPERATIONAL_ERROR)")
void zweiArgumente_liefernExitCode2() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
ByteArrayOutputStream err = captureStderr();
int exitCode = runner.run(new String[]{"datei1.txt", "datei2.txt"});
restoreStderr();
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode);
verifyNoInteractions(service);
}
// -----------------------------------------------------------------------
// Datei-Vorabprüfung
// -----------------------------------------------------------------------
@Test
@DisplayName("Nicht existierende Datei → Exit-Code 2 (OPERATIONAL_ERROR)")
void nichtExistierendeDatei_liefertExitCode2() {
FileValidationService service = mock(FileValidationService.class);
CliRunner runner = runnerWith(service);
ByteArrayOutputStream err = captureStderr();
int exitCode = runner.run(new String[]{"/nicht/vorhanden/datei.txt"});
restoreStderr();
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode);
assertTrue(err.toString().contains("Fehler"),
"STDERR soll eine deutsche Fehlermeldung enthalten");
verifyNoInteractions(service);
}
// -----------------------------------------------------------------------
// Erfolgreicher Lauf
// -----------------------------------------------------------------------
@Test
@DisplayName("Leere, lesbare Datei ohne Spec-Fehler → Exit-Code 0 (VALID)")
void leereLesbareDatei_liefertExitCode0() throws IOException {
Path testFile = tempDir.resolve("leer.auf");
Files.createFile(testFile);
ValidationReport emptyReport = new ValidationReport("leer.auf", Instant.now(), List.of());
FileValidationService service = mock(FileValidationService.class);
when(service.validate(any(Path.class))).thenReturn(emptyReport);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{testFile.toString()});
assertEquals(ExitCode.VALID, exitCode);
verify(service).validate(any(Path.class));
}
@Test
@DisplayName("Datei mit SPEC-ERROR-Befund → Exit-Code 1 (INVALID)")
void dateimitSpecFehler_liefertExitCode1() throws IOException {
Path testFile = tempDir.resolve("fehlerhaft.auf");
Files.writeString(testFile, "irgendein Inhalt");
Finding specError = Finding.builder(
FindingKind.SPEC, Severity.ERROR, FindingLayer.ARTIFACT, "Testfehler").build();
ValidationReport reportWithError = new ValidationReport(
"fehlerhaft.auf", Instant.now(), List.of(specError));
FileValidationService service = mock(FileValidationService.class);
when(service.validate(any(Path.class))).thenReturn(reportWithError);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{testFile.toString()});
assertEquals(ExitCode.INVALID, exitCode);
}
@Test
@DisplayName("ValidationReport mit operationalError → Exit-Code 2 (OPERATIONAL_ERROR)")
void operationalErrorReport_liefertExitCode2() throws IOException {
Path testFile = tempDir.resolve("bedien.auf");
Files.writeString(testFile, "Inhalt");
ValidationReport errReport = ValidationReport.operationalError(
"bedien.auf", "CLI-001", "Bedienfehler");
FileValidationService service = mock(FileValidationService.class);
when(service.validate(any(Path.class))).thenReturn(errReport);
CliRunner runner = runnerWith(service);
int exitCode = runner.run(new String[]{testFile.toString()});
assertEquals(ExitCode.OPERATIONAL_ERROR, exitCode);
}
@Test
@DisplayName("Konsolenausgabe (stdout) enthält Berichtinhalt mit GÜLTIG-Urteil")
void konsolenausgabe_enthaeltBerichtinhalt() throws IOException {
Path testFile = tempDir.resolve("konsole.auf");
Files.createFile(testFile);
ValidationReport emptyReport = new ValidationReport("konsole.auf", Instant.now(), List.of());
FileValidationService service = mock(FileValidationService.class);
when(service.validate(any(Path.class))).thenReturn(emptyReport);
PrintStream originalOut = System.out;
ByteArrayOutputStream outBuf = new ByteArrayOutputStream();
System.setOut(new PrintStream(outBuf));
try {
CliRunner runner = runnerWith(service);
runner.run(new String[]{testFile.toString()});
} finally {
System.setOut(originalOut);
}
String output = outBuf.toString();
assertTrue(output.contains("GÜLTIG"),
"Konsolenausgabe soll 'GÜLTIG' enthalten");
assertTrue(output.contains("ASV-Format-Validator"),
"Konsolenausgabe soll Berichtkopf enthalten");
}
@Test
@DisplayName("Berichtdatei wird nach Lauf im Verzeichnis der Eingabedatei erzeugt")
void berichtdatei_wirdNachLaufErzeugt() throws IOException {
Path testFile = tempDir.resolve("bericht.auf");
Files.createFile(testFile);
ValidationReport emptyReport = new ValidationReport("bericht.auf", Instant.now(), List.of());
FileValidationService service = mock(FileValidationService.class);
when(service.validate(any(Path.class))).thenReturn(emptyReport);
CliRunner runner = runnerWith(service);
runner.run(new String[]{testFile.toString()});
Path expectedReport = tempDir.resolve("bericht.auf.txt");
assertTrue(Files.exists(expectedReport),
"Berichtdatei soll existieren: " + expectedReport);
}
// -----------------------------------------------------------------------
// Hilfsmethoden
// -----------------------------------------------------------------------
private PrintStream originalStderr;
private ByteArrayOutputStream captureStderr() {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
originalStderr = System.err;
System.setErr(new PrintStream(buf));
return buf;
}
private void restoreStderr() {
System.setErr(originalStderr);
}
}
@@ -0,0 +1,130 @@
package de.gecheckt.asv.adapter.out.filesystem;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Unit-Tests für {@link SuffixResolver}.
*/
class SuffixResolverTest {
private final SuffixResolver resolver = new SuffixResolver();
// -------------------------------------------------------------------------
// Normalfälle
// -------------------------------------------------------------------------
@Test
@DisplayName("Keine Datei vorhanden → Basisname ohne Suffix")
void keineDateiVorhanden_gibtBasisnameOhneSuffix(@TempDir Path tempDir) {
Path result = resolver.resolveNextFreePath(tempDir, "foo.auf", "txt");
assertEquals(tempDir.resolve("foo.auf.txt"), result);
assertFalse(Files.exists(result), "Ergebnis darf noch nicht existieren");
}
@Test
@DisplayName("Basisname.txt vorhanden → _v1.txt")
void txtVorhanden_gibtV1Suffix(@TempDir Path tempDir) throws IOException {
Files.createFile(tempDir.resolve("foo.auf.txt"));
Path result = resolver.resolveNextFreePath(tempDir, "foo.auf", "txt");
assertEquals(tempDir.resolve("foo.auf_v1.txt"), result);
assertFalse(Files.exists(result), "Ergebnis darf noch nicht existieren");
}
@Test
@DisplayName("Basisname.txt und _v1.txt vorhanden → _v2.txt")
void txtUndV1Vorhanden_gibtV2Suffix(@TempDir Path tempDir) throws IOException {
Files.createFile(tempDir.resolve("foo.auf.txt"));
Files.createFile(tempDir.resolve("foo.auf_v1.txt"));
Path result = resolver.resolveNextFreePath(tempDir, "foo.auf", "txt");
assertEquals(tempDir.resolve("foo.auf_v2.txt"), result);
assertFalse(Files.exists(result), "Ergebnis darf noch nicht existieren");
}
@Test
@DisplayName("Suffix-Zählung ist pro Extension unabhängig: .txt zählt nicht für .log")
void txtZaehltNichtFuerLog(@TempDir Path tempDir) throws IOException {
// .txt vorhanden → für txt wäre _v1.txt nötig
Files.createFile(tempDir.resolve("foo.auf.txt"));
Files.createFile(tempDir.resolve("foo.auf_v1.txt"));
// .log ist unberührt → kein Suffix nötig
Path logResult = resolver.resolveNextFreePath(tempDir, "foo.auf", "log");
assertEquals(tempDir.resolve("foo.auf.log"), logResult);
assertFalse(Files.exists(logResult), "Ergebnis darf noch nicht existieren");
}
@Test
@DisplayName("Drei aufeinanderfolgende Läufe erzeugen korrekte Suffixfolge")
void dreiLaeufe_erzeugenKorrekteSuffixfolge(@TempDir Path tempDir) throws IOException {
// Lauf 1 → kein Suffix
Path first = resolver.resolveNextFreePath(tempDir, "bar.auf", "txt");
assertEquals(tempDir.resolve("bar.auf.txt"), first);
Files.createFile(first);
// Lauf 2 → _v1
Path second = resolver.resolveNextFreePath(tempDir, "bar.auf", "txt");
assertEquals(tempDir.resolve("bar.auf_v1.txt"), second);
Files.createFile(second);
// Lauf 3 → _v2
Path third = resolver.resolveNextFreePath(tempDir, "bar.auf", "txt");
assertEquals(tempDir.resolve("bar.auf_v2.txt"), third);
}
@Test
@DisplayName("Basisname mit Punkt (z.B. 'foo.auf') wird korrekt behandelt")
void baseName_mitPunkt_wirdKorrektBehandelt(@TempDir Path tempDir) {
Path result = resolver.resolveNextFreePath(tempDir, "foo.auf", "txt");
// Dateiname muss sein: foo.auf.txt (Punkt aus baseName + Punkt + ext)
assertEquals("foo.auf.txt", result.getFileName().toString());
}
// -------------------------------------------------------------------------
// Fehlerfälle
// -------------------------------------------------------------------------
@Test
@DisplayName("Null directory → IllegalArgumentException")
void nullDirectory_wirft_IllegalArgumentException() {
assertThrows(IllegalArgumentException.class,
() -> resolver.resolveNextFreePath(null, "foo", "txt"));
}
@Test
@DisplayName("Null baseName → IllegalArgumentException")
void nullBaseName_wirft_IllegalArgumentException(@TempDir Path tempDir) {
assertThrows(IllegalArgumentException.class,
() -> resolver.resolveNextFreePath(tempDir, null, "txt"));
}
@Test
@DisplayName("Leerer baseName → IllegalArgumentException")
void leererBaseName_wirft_IllegalArgumentException(@TempDir Path tempDir) {
assertThrows(IllegalArgumentException.class,
() -> resolver.resolveNextFreePath(tempDir, " ", "txt"));
}
@Test
@DisplayName("Null extension → IllegalArgumentException")
void nullExtension_wirft_IllegalArgumentException(@TempDir Path tempDir) {
assertThrows(IllegalArgumentException.class,
() -> resolver.resolveNextFreePath(tempDir, "foo", null));
}
}
@@ -0,0 +1,206 @@
package de.gecheckt.asv.adapter.out.reporting;
import de.gecheckt.asv.adapter.out.filesystem.SuffixResolver;
import de.gecheckt.asv.domain.finding.Finding;
import de.gecheckt.asv.domain.finding.FindingKind;
import de.gecheckt.asv.domain.finding.FindingLayer;
import de.gecheckt.asv.domain.finding.Severity;
import de.gecheckt.asv.domain.finding.ValidationReport;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit-Tests für {@link ReportFileWriter}.
*/
class ReportFileWriterTest {
private final SuffixResolver suffixResolver = new SuffixResolver();
private final ReportFileWriter writer = new ReportFileWriter(suffixResolver);
// -------------------------------------------------------------------------
// Grundfunktion: Datei wird erzeugt
// -------------------------------------------------------------------------
@Test
@DisplayName("Leerer Report → Berichtdatei wird im Verzeichnis der Eingabedatei erzeugt")
void leererReport_erzeugtBerichtdateiImEingabeverzeichnis(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("test.auf");
Files.createFile(inputFile);
ValidationReport report = new ValidationReport("test.auf", Instant.now(), List.of());
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
assertTrue(result.isSuccess(), "Schreibvorgang soll erfolgreich sein");
assertEquals(tempDir.resolve("test.auf.txt"), result.reportPath());
assertTrue(Files.exists(result.reportPath()), "Berichtdatei soll existieren");
}
@Test
@DisplayName("Berichtdatei ist in UTF-8 kodiert (Sonderzeichen äöü߀)")
void berichtdatei_istInUtf8(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("test.auf");
Files.createFile(inputFile);
Finding finding = Finding.builder(FindingKind.SPEC, Severity.ERROR,
FindingLayer.ARTIFACT, "Ungültiges Feld — Sonderzeichen: äöü߀").build();
ValidationReport report = new ValidationReport("test.auf", Instant.now(), List.of(finding));
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
assertTrue(result.isSuccess());
byte[] bytes = Files.readAllBytes(result.reportPath());
String content = new String(bytes, StandardCharsets.UTF_8);
assertTrue(content.contains("äöü߀"),
"UTF-8-dekodierter Inhalt soll Sonderzeichen enthalten");
}
@Test
@DisplayName("Kopfzeile enthält Zeitstempel, Eingabedatei und Urteil GÜLTIG")
void kopfzeile_enthaeltZeitstempelEingabedateiUrteil(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("bar.auf");
Files.createFile(inputFile);
Instant now = Instant.parse("2026-04-20T10:30:00Z");
ValidationReport report = new ValidationReport("bar.auf", now, List.of());
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
String content = result.reportContent();
assertTrue(content.contains("2026-04-20T10:30:00Z"),
"Kopfzeile soll ISO-Zeitstempel enthalten");
assertTrue(content.contains("bar.auf"),
"Kopfzeile soll Dateinamen enthalten");
assertTrue(content.contains("GÜLTIG"),
"Kopfzeile soll Urteil 'GÜLTIG' enthalten");
}
@Test
@DisplayName("Pro Finding wird eine Zeile mit Severity, Kind, Layer und Meldung ausgegeben")
void proFinding_wirdEineZeileAusgegeben(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
Finding finding = Finding.builder(FindingKind.SPEC, Severity.ERROR,
FindingLayer.TECHNICAL_STRUCTURE, "Pflichtfeld fehlt")
.fieldId("UNB_0020")
.build();
ValidationReport report = new ValidationReport("foo.auf", Instant.now(), List.of(finding));
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
String content = result.reportContent();
assertTrue(content.contains("[ERROR]"), "Zeile soll Severity enthalten");
assertTrue(content.contains("[SPEC]"), "Zeile soll Kind enthalten");
assertTrue(content.contains("[TECHNICAL_STRUCTURE]"), "Zeile soll Layer enthalten");
assertTrue(content.contains("UNB_0020"), "Zeile soll Feld-ID enthalten");
assertTrue(content.contains("Pflichtfeld fehlt"), "Zeile soll deutsche Meldung enthalten");
}
@Test
@DisplayName("Fußzeile enthält Hinweis auf M1-Platzhalter-Validator")
void fuszeile_enthaeltHinweisAufM1Platzhalter(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
ValidationReport report = new ValidationReport("foo.auf", Instant.now(), List.of());
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
assertTrue(result.reportContent().contains("M1-Platzhalter"),
"Fußzeile soll Hinweis auf M1-Platzhalter enthalten");
}
@Test
@DisplayName("Zweiter Lauf → Suffix _v1.txt")
void zweiterLauf_gibtV1Suffix(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("foo.auf");
Files.createFile(inputFile);
ValidationReport report = new ValidationReport("foo.auf", Instant.now(), List.of());
// Erster Lauf
ReportFileWriter.ReportWriteResult first = writer.write(report, inputFile);
assertEquals(tempDir.resolve("foo.auf.txt"), first.reportPath(),
"Erster Lauf soll keinen Suffix erzeugen");
// Zweiter Lauf
ValidationReport report2 = new ValidationReport("foo.auf", Instant.now(), List.of());
ReportFileWriter.ReportWriteResult second = writer.write(report2, inputFile);
assertEquals(tempDir.resolve("foo.auf_v1.txt"), second.reportPath(),
"Zweiter Lauf soll _v1-Suffix erzeugen");
}
@Test
@DisplayName("UNGÜLTIG-Urteil erscheint im Bericht")
void ungueltigUrteil_erscheintImBericht(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("invalid.auf");
Files.createFile(inputFile);
Finding specError = Finding.builder(FindingKind.SPEC, Severity.ERROR,
FindingLayer.ARTIFACT, "Kritischer Fehler").build();
ValidationReport report = new ValidationReport(
"invalid.auf", Instant.now(), List.of(specError));
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
assertTrue(result.reportContent().contains("UNGÜLTIG"),
"Bericht soll 'UNGÜLTIG' bei SPEC-ERROR enthalten");
}
@Test
@DisplayName("Keine Befunde → Zeile 'Keine Befunde' erscheint im Bericht")
void keineBefunde_zeigtPlatzhaltertext(@TempDir Path tempDir) throws IOException {
Path inputFile = tempDir.resolve("leer.auf");
Files.createFile(inputFile);
ValidationReport report = new ValidationReport("leer.auf", Instant.now(), List.of());
ReportFileWriter.ReportWriteResult result = writer.write(report, inputFile);
assertTrue(result.reportContent().contains("Keine Befunde"),
"Bericht soll 'Keine Befunde' bei leerem Report enthalten");
}
// -------------------------------------------------------------------------
// Fehlerfälle
// -------------------------------------------------------------------------
@Test
@DisplayName("Null report → IllegalArgumentException")
void nullReport_wirft_IllegalArgumentException(@TempDir Path tempDir) {
Path inputFile = tempDir.resolve("foo.auf");
assertThrows(IllegalArgumentException.class,
() -> writer.write(null, inputFile));
}
@Test
@DisplayName("Null inputFilePath → IllegalArgumentException")
void nullInputFilePath_wirft_IllegalArgumentException() {
ValidationReport report = new ValidationReport("foo.auf", Instant.now(), List.of());
assertThrows(IllegalArgumentException.class,
() -> writer.write(report, null));
}
@Test
@DisplayName("ReportWriteResult.isSuccess gibt true zurück wenn reportPath gesetzt")
void reportWriteResult_isSuccess_true_wenn_Pfad_gesetzt() {
ReportFileWriter.ReportWriteResult result =
new ReportFileWriter.ReportWriteResult("inhalt", Path.of("foo.txt"), null);
assertTrue(result.isSuccess());
assertNotNull(result.reportPath());
assertNull(result.writeException());
}
}
@@ -0,0 +1,75 @@
package de.gecheckt.asv.application;
import de.gecheckt.asv.domain.finding.ValidationReport;
import de.gecheckt.asv.domain.finding.Verdict;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Tests für {@link DummyFileValidationService}.
*
* <p>Schwerpunkt: Nachweis der korrekten ISO-8859-15-Dekodierung gemäß AP06-Abnahmekriterium
* „Byte {@code 0xA4} ergibt Euro-Zeichen €".</p>
*/
class DummyFileValidationServiceTest {
@TempDir
Path tempDir;
@Test
@DisplayName("Leere Datei liefert leeren ValidationReport mit Verdict VALID")
void leereDatei_liefertLeerenReport() throws IOException {
Path leereDatei = tempDir.resolve("leer.txt");
Files.createFile(leereDatei);
DummyFileValidationService service = new DummyFileValidationService();
ValidationReport report = service.validate(leereDatei);
assertNotNull(report);
assertEquals(Verdict.VALID, report.computeVerdict());
assertEquals("leer.txt", report.getFileName());
assertNotNull(report.getTimestamp());
}
@Test
@DisplayName("Datei mit Inhalt liefert ValidationReport mit Verdict VALID (kein Spec-Fehler in M1)")
void dateiMitInhalt_liefertVALID() throws IOException {
Path datei = tempDir.resolve("inhalt.txt");
Files.writeString(datei, "Test-Inhalt");
DummyFileValidationService service = new DummyFileValidationService();
ValidationReport report = service.validate(datei);
assertEquals(Verdict.VALID, report.computeVerdict());
}
@Test
@DisplayName("Byte 0xA4 in ISO-8859-15 wird als Euro-Zeichen € dekodiert")
void byte0xA4_wirdAlsEuroZeichenDekodiert() throws IOException {
// Byte 0xA4 ist in ISO-8859-15 dem Euro-Zeichen € zugewiesen.
// (In ISO-8859-1 wäre 0xA4 das Währungszeichen ¤ — daher dieser Test.)
byte[] inhalt = new byte[]{0x54, 0x65, 0x73, 0x74, (byte) 0xA4}; // "Test" + 0xA4
Path datei = tempDir.resolve("euro.bin");
Files.write(datei, inhalt);
// Dekodierung mit dem in DummyFileValidationService verwendeten Charset
String dekodiert = new String(inhalt, DummyFileValidationService.INPUT_CHARSET);
assertEquals("Test€", dekodiert,
"Byte 0xA4 muss in ISO-8859-15 als Euro-Zeichen € dekodiert werden");
}
@Test
@DisplayName("INPUT_CHARSET ist ISO-8859-15 (nicht UTF-8, nicht Plattform-Default)")
void inputCharset_istISO8859_15() {
assertEquals("ISO-8859-15", DummyFileValidationService.INPUT_CHARSET.name());
}
}
@@ -0,0 +1,117 @@
package de.gecheckt.asv.bootstrap;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import de.gecheckt.asv.application.DefaultInputFileValidator;
import de.gecheckt.asv.application.model.ValidationResult;
import de.gecheckt.asv.domain.model.Field;
import de.gecheckt.asv.domain.model.InputFile;
import de.gecheckt.asv.domain.model.Message;
import de.gecheckt.asv.domain.model.Segment;
/**
* Integrationstest für den M1-Einfrierzustand der Preview-Validatoren.
*
* <p>Belegt, dass ein Lauf mit {@link NoOpStructureValidator} und {@link NoOpFieldValidator}
* keinerlei ASVREC-/ASVFEH-Segmentbefunde erzeugt — unabhängig davon, ob die Eingabedatei
* ASV-Strukturmerkmale enthält oder nicht.</p>
*
* <p>Dieser Test ist der formale Nachweis für AP09-Abnahmekriterium
* „Integrationstest: Lauf mit Testdatei erzeugt keine ASVREC-/ASVFEH-Segmentbefunde".</p>
*/
class NoOpValidatorsIntegrationTest {
/**
* Erstellt einen {@link DefaultInputFileValidator} mit den M1-NoOp-Implementierungen.
*/
private DefaultInputFileValidator buildValidator() {
return new DefaultInputFileValidator(
new NoOpStructureValidator(),
new NoOpFieldValidator()
);
}
@Test
@DisplayName("KRITISCH: NoOp-Validatoren erzeugen keine Befunde für ASVREC-Struktur")
void asvrecStruktur_erzeugtKeineBefunde() {
// Given: eine vollständige ASVREC-Nachricht (die DefaultStructureValidator prüfen würde)
Segment unh = new Segment("UNH", 1, List.of(new Field(1, "12345"), new Field(2, "ASVREC:D:03B:UN:EAN008")));
Segment ifa = new Segment("IFA", 2, List.of(new Field(1, "IFA-Wert")));
Segment rea = new Segment("REA", 3, List.of(new Field(1, "100,00"), new Field(2, "X"), new Field(3, "Y"), new Field(4, "0")));
Segment dgn = new Segment("DGN", 4, List.of(new Field(1, "DGN-Wert")));
Segment lea = new Segment("LEA", 5, List.of(new Field(1, "LEA-Wert")));
Segment iva = new Segment("IVA", 6, List.of(new Field(1, "IVA-Wert")));
Segment unt = new Segment("UNT", 7, List.of(new Field(1, "7"), new Field(2, "12345")));
Message message = new Message(1, List.of(unh, ifa, rea, dgn, lea, iva, unt));
InputFile inputFile = new InputFile("testdatei.asv", List.of(message));
// When
ValidationResult result = buildValidator().validate(inputFile);
// Then: keinerlei Befunde — NoOp-Validatoren produzieren nie Findings
assertTrue(result.getAllErrors().isEmpty(),
"NoOp-Validatoren dürfen keinerlei Befunde erzeugen, gefunden: " + result.getAllErrors());
assertFalse(result.hasErrors(), "Keine Fehler erwartet");
assertFalse(result.hasWarnings(), "Keine Warnungen erwartet");
}
@Test
@DisplayName("NoOp-Validatoren erzeugen keine Befunde für ASVFEH-Struktur")
void asvfehStruktur_erzeugtKeineBefunde() {
// Given: eine ASVFEH-Nachricht mit FHL-Segment
Segment unh = new Segment("UNH", 1, List.of(new Field(1, "99999"), new Field(2, "ASVFEH:D:03B:UN:EAN008")));
Segment fhl = new Segment("FHL", 2, List.of(new Field(1, "Fehler-Hinweis")));
Segment unt = new Segment("UNT", 3, List.of(new Field(1, "3"), new Field(2, "99999")));
Message message = new Message(1, List.of(unh, fhl, unt));
InputFile inputFile = new InputFile("testdatei-feh.asv", List.of(message));
// When
ValidationResult result = buildValidator().validate(inputFile);
// Then: keinerlei Befunde
assertTrue(result.getAllErrors().isEmpty(),
"NoOp-Validatoren dürfen keinerlei Befunde erzeugen, gefunden: " + result.getAllErrors());
}
@Test
@DisplayName("NoOp-Validatoren erzeugen keine Befunde für leere Eingabedatei")
void leereEingabedatei_erzeugtKeineBefunde() {
// Given: eine Eingabedatei ohne Nachrichten
InputFile inputFile = new InputFile("leer.asv", List.of());
// When
ValidationResult result = buildValidator().validate(inputFile);
// Then: keinerlei Befunde — auch STRUCTURE_001 (fehlende Nachricht) wird nicht gemeldet
assertTrue(result.getAllErrors().isEmpty(),
"NoOp-Validatoren dürfen auch für leere Eingabedatei keine Befunde erzeugen");
}
@Test
@DisplayName("NoOpStructureValidator wirft IllegalArgumentException bei null-Eingabe")
void noOpStructureValidator_wirftExceptionBeiNull() {
NoOpStructureValidator validator = new NoOpStructureValidator();
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> validator.validate(null),
"Null-Eingabe muss IllegalArgumentException auslösen"
);
}
@Test
@DisplayName("NoOpFieldValidator wirft IllegalArgumentException bei null-Eingabe")
void noOpFieldValidator_wirftExceptionBeiNull() {
NoOpFieldValidator validator = new NoOpFieldValidator();
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> validator.validate(null),
"Null-Eingabe muss IllegalArgumentException auslösen"
);
}
}
@@ -0,0 +1,124 @@
package de.gecheckt.asv.domain.finding;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit-Tests für {@link Finding}.
*/
class FindingTest {
@Test
@DisplayName("Finding-Record mit allen Feldern ist korrekt befüllbar")
void findingRecordAllFields() {
Finding f = new Finding(
FindingKind.SPEC,
Severity.ERROR,
FindingLayer.TECHNICAL_STRUCTURE,
"RULE-001",
"1A001",
"UNB",
0,
"UNB_0020",
"FALSCH",
42,
"00001",
"Referenznummer stimmt nicht überein."
);
assertEquals(FindingKind.SPEC, f.kind());
assertEquals(Severity.ERROR, f.severity());
assertEquals(FindingLayer.TECHNICAL_STRUCTURE, f.layer());
assertEquals("RULE-001", f.ruleId());
assertEquals("1A001", f.officialErrorCode());
assertEquals("UNB", f.segmentType());
assertEquals(0, f.segmentIndex());
assertEquals("UNB_0020", f.fieldId());
assertEquals("FALSCH", f.rawValue());
assertEquals(42, f.position());
assertEquals("00001", f.messageReference());
assertEquals("Referenznummer stimmt nicht überein.", f.germanMessage());
}
@Test
@DisplayName("Finding-Builder befüllt Pflichtfelder korrekt, optionale Felder null")
void findingBuilderMindestfelder() {
Finding f = Finding.builder(
FindingKind.DIAGNOSTIC, Severity.HINT, FindingLayer.ARTIFACT,
"Hinweis auf mögliche Anomalie.")
.build();
assertEquals(FindingKind.DIAGNOSTIC, f.kind());
assertEquals(Severity.HINT, f.severity());
assertEquals(FindingLayer.ARTIFACT, f.layer());
assertEquals("Hinweis auf mögliche Anomalie.", f.germanMessage());
assertNull(f.ruleId());
assertNull(f.officialErrorCode());
assertNull(f.segmentType());
assertNull(f.segmentIndex());
assertNull(f.fieldId());
assertNull(f.rawValue());
assertNull(f.position());
assertNull(f.messageReference());
}
@Test
@DisplayName("Finding-Builder befüllt optionale Felder korrekt")
void findingBuilderOptionalFelder() {
Finding f = Finding.builder(
FindingKind.SPEC, Severity.WARNING, FindingLayer.DOMAIN_MODEL,
"Segment fehlt.")
.ruleId("RULE-042")
.officialErrorCode("2A001")
.segmentType("REA")
.segmentIndex(5)
.fieldId("REA_0010")
.rawValue("")
.position(1024)
.messageReference("MSG00001")
.build();
assertEquals("RULE-042", f.ruleId());
assertEquals("2A001", f.officialErrorCode());
assertEquals("REA", f.segmentType());
assertEquals(5, f.segmentIndex());
assertEquals("REA_0010", f.fieldId());
assertEquals("", f.rawValue());
assertEquals(1024, f.position());
assertEquals("MSG00001", f.messageReference());
}
@Test
@DisplayName("isSpecError() gibt true nur bei SPEC+ERROR")
void isSpecErrorNurBeiSpecUndError() {
Finding specError = Finding.builder(FindingKind.SPEC, Severity.ERROR,
FindingLayer.ARTIFACT, "Fehler.").build();
Finding specWarning = Finding.builder(FindingKind.SPEC, Severity.WARNING,
FindingLayer.ARTIFACT, "Warnung.").build();
Finding diagError = Finding.builder(FindingKind.DIAGNOSTIC, Severity.ERROR,
FindingLayer.ARTIFACT, "Diagnose-Fehler.").build();
assertTrue(specError.isSpecError(), "SPEC+ERROR muss isSpecError() = true ergeben.");
assertFalse(specWarning.isSpecError(), "SPEC+WARNING muss isSpecError() = false ergeben.");
assertFalse(diagError.isSpecError(), "DIAGNOSTIC+ERROR muss isSpecError() = false ergeben.");
}
@Test
@DisplayName("Finding-Konstruktor wirft NullPointerException bei null-germanMessage")
void konstruktorWirftNPEBeiNullGermanMessage() {
assertThrows(NullPointerException.class,
() -> new Finding(FindingKind.SPEC, Severity.ERROR, FindingLayer.ARTIFACT,
null, null, null, null, null, null, null, null, null));
}
@Test
@DisplayName("Finding-Konstruktor wirft NullPointerException bei null-kind")
void konstruktorWirftNPEBeiNullKind() {
assertThrows(NullPointerException.class,
() -> new Finding(null, Severity.ERROR, FindingLayer.ARTIFACT,
null, null, null, null, null, null, null, null, "Meldung."));
}
}
@@ -0,0 +1,212 @@
package de.gecheckt.asv.domain.finding;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit-Tests für {@link ValidationReport}.
*
* <p>Die kritische Invariante lautet: Nur SPEC-ERROR-Befunde führen zu {@link Verdict#INVALID}.
* Diagnostische Befunde — auch solche mit {@link Severity#ERROR} — dürfen das Urteil
* niemals auf {@link Verdict#INVALID} setzen.</p>
*/
class ValidationReportTest {
// ---------------------------------------------------------------------------
// Hilfsmethoden
// ---------------------------------------------------------------------------
private static Finding specError() {
return Finding.builder(FindingKind.SPEC, Severity.ERROR, FindingLayer.ARTIFACT,
"Pflichtfeld fehlt.").build();
}
private static Finding specWarning() {
return Finding.builder(FindingKind.SPEC, Severity.WARNING, FindingLayer.ARTIFACT,
"Dateiname weicht vom Schema ab.").build();
}
private static Finding diagnosticError() {
return Finding.builder(FindingKind.DIAGNOSTIC, Severity.ERROR, FindingLayer.TECHNICAL_STRUCTURE,
"Diagnostischer Fehler — beeinflusst das Urteil nicht.").build();
}
private static Finding diagnosticWarning() {
return Finding.builder(FindingKind.DIAGNOSTIC, Severity.WARNING, FindingLayer.DOMAIN_MODEL,
"Diagnostische Warnung.").build();
}
private static ValidationReport emptyReport() {
return new ValidationReport("testdatei.txt", Instant.now(), List.of());
}
// ---------------------------------------------------------------------------
// Test 1: Leerer Report → VALID
// ---------------------------------------------------------------------------
@Test
@DisplayName("Leerer Report liefert Verdict VALID")
void leeremReportLiefertVALID() {
ValidationReport report = emptyReport();
assertEquals(Verdict.VALID, report.computeVerdict(),
"Ein leerer Report ohne Befunde muss VALID liefern.");
}
// ---------------------------------------------------------------------------
// Test 2: Ein SPEC-ERROR → INVALID
// ---------------------------------------------------------------------------
@Test
@DisplayName("Ein SPEC-ERROR-Befund liefert Verdict INVALID")
void specErrorLiefertINVALID() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(specError()));
assertEquals(Verdict.INVALID, report.computeVerdict(),
"Ein SPEC-ERROR-Befund muss zu INVALID führen.");
}
// ---------------------------------------------------------------------------
// Test 3 (kritisch): Ein DIAGNOSTIC-ERROR → VALID
// ---------------------------------------------------------------------------
@Test
@DisplayName("KRITISCH: Ein DIAGNOSTIC-ERROR-Befund liefert Verdict VALID (niemals INVALID)")
void diagnosticErrorLiefertVALID() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(diagnosticError()));
assertEquals(Verdict.VALID, report.computeVerdict(),
"Ein DIAGNOSTIC-ERROR-Befund darf das Urteil NIEMALS auf INVALID setzen. "
+ "Nur SPEC+ERROR zählt für das Gesamturteil.");
}
// ---------------------------------------------------------------------------
// Test 4: SPEC-WARNING → VALID
// ---------------------------------------------------------------------------
@Test
@DisplayName("Ein SPEC-WARNING-Befund liefert Verdict VALID")
void specWarningLiefertVALID() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(specWarning()));
assertEquals(Verdict.VALID, report.computeVerdict(),
"Nur SPEC+ERROR macht eine Datei ungültig — Warnungen nicht.");
}
// ---------------------------------------------------------------------------
// Test 5: specFindings() / diagnosticFindings() filtern korrekt
// ---------------------------------------------------------------------------
@Test
@DisplayName("specFindings() und diagnosticFindings() filtern korrekt")
void findingsFilterungKorrekt() {
Finding spec1 = specError();
Finding spec2 = specWarning();
Finding diag1 = diagnosticError();
Finding diag2 = diagnosticWarning();
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(spec1, diag1, spec2, diag2));
List<Finding> specList = report.specFindings();
List<Finding> diagList = report.diagnosticFindings();
assertEquals(2, specList.size(), "specFindings() muss genau 2 SPEC-Befunde zurückliefern.");
assertEquals(2, diagList.size(), "diagnosticFindings() muss genau 2 DIAGNOSTIC-Befunde zurückliefern.");
assertTrue(specList.stream().allMatch(f -> f.kind() == FindingKind.SPEC),
"specFindings() darf nur SPEC-Befunde enthalten.");
assertTrue(diagList.stream().allMatch(f -> f.kind() == FindingKind.DIAGNOSTIC),
"diagnosticFindings() darf nur DIAGNOSTIC-Befunde enthalten.");
}
// ---------------------------------------------------------------------------
// Test 6: findings-Liste ist nicht von außen modifizierbar
// ---------------------------------------------------------------------------
@Test
@DisplayName("Die findings-Liste ist von außen nicht modifizierbar")
void findingsListeNichtModifizierbar() {
List<Finding> mutableList = new ArrayList<>();
mutableList.add(specError());
ValidationReport report = new ValidationReport("testdatei.txt", Instant.now(), mutableList);
// Versuch 1: Originalliste nach Übergabe verändern
mutableList.add(diagnosticError());
assertEquals(1, report.getFindings().size(),
"Änderungen an der Eingabeliste dürfen den Report nicht beeinflussen.");
// Versuch 2: Zurückgegebene Liste direkt verändern
assertThrows(UnsupportedOperationException.class,
() -> report.getFindings().add(specWarning()),
"getFindings() muss eine unveränderliche Liste zurückliefern.");
}
// ---------------------------------------------------------------------------
// Test 7: operationalError(...) → OPERATIONAL_ERROR
// ---------------------------------------------------------------------------
@Test
@DisplayName("operationalError-Factory liefert Verdict OPERATIONAL_ERROR")
void operationalErrorFactoryLiefertOPERATIONAL_ERROR() {
ValidationReport report = ValidationReport.operationalError(
"nichtvorhanden.txt", "SYS-001", "Eingabedatei nicht lesbar.");
assertEquals(Verdict.OPERATIONAL_ERROR, report.computeVerdict(),
"operationalError() muss immer OPERATIONAL_ERROR liefern.");
assertFalse(report.getFindings().isEmpty(),
"Der Bedienfehler-Bericht muss mindestens einen Befund enthalten.");
assertEquals("nichtvorhanden.txt", report.getFileName());
}
// ---------------------------------------------------------------------------
// Zusätzliche Absicherung: DIAGNOSTIC-ERROR gemeinsam mit SPEC-ERROR
// ---------------------------------------------------------------------------
@Test
@DisplayName("SPEC-ERROR + DIAGNOSTIC-ERROR: Verdict ist INVALID (wegen SPEC-ERROR)")
void specErrorUndDiagnosticError_liefertINVALID() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(specError(), diagnosticError()));
assertEquals(Verdict.INVALID, report.computeVerdict(),
"Wenn sowohl SPEC-ERROR als auch DIAGNOSTIC-ERROR vorliegen, muss INVALID gelten "
+ "— aber nur wegen des SPEC-ERROR.");
}
// ---------------------------------------------------------------------------
// Zusätzliche Absicherung: hasSpecErrors()
// ---------------------------------------------------------------------------
@Test
@DisplayName("hasSpecErrors() gibt false zurück bei leerem Report")
void hasSpecErrors_leerReport() {
assertFalse(emptyReport().hasSpecErrors());
}
@Test
@DisplayName("hasSpecErrors() gibt true zurück bei SPEC-ERROR")
void hasSpecErrors_mitSpecError() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(specError()));
assertTrue(report.hasSpecErrors());
}
@Test
@DisplayName("hasSpecErrors() gibt false zurück bei nur DIAGNOSTIC-ERROR")
void hasSpecErrors_nurDiagnosticError() {
ValidationReport report = new ValidationReport(
"testdatei.txt", Instant.now(), List.of(diagnosticError()));
assertFalse(report.hasSpecErrors(),
"DIAGNOSTIC-ERROR darf hasSpecErrors() nicht auf true setzen.");
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_ERR">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="WARN">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>