package reports import ( "bytes" "encoding/json" "strings" "testing" "time" ) // buildSampleReport erzeugt einen Bericht mit allen Bestandteilen. func buildSampleReport() *Report { referenceTime := time.Date(2026, 8, 12, 9, 30, 0, 0, time.UTC) sampleReport := &Report{ Type: TypeDailyBackup, Title: "Tagesbericht Sicherungen", Description: "Alle Sicherungsläufe eines Tages mit Ergebnis und Größe.", PeriodFrom: referenceTime.Add(-24 * time.Hour), PeriodTo: referenceTime, GeneratedAt: referenceTime, GeneratedBy: "pruefer", Sections: []Section{ { Title: "Überblick", Metrics: []Metric{ KnownMetric("Läufe insgesamt", 12, UnitCount), KnownMetric("Gesicherte Datenmenge", 1503238553, UnitBytes), UnknownMetric("Erfolgsquote", UnitPercent, "Im gewählten Zeitraum gab es keinen Lauf."), TextMetric("Protokollierung", "aktiv"), }, Tables: []Table{ { Title: "Läufe", Columns: []string{"Auftrag", "Ergebnis", "Größe"}, Rows: [][]string{ {"Dateiserver Dokumente", "erfolgreich", "1,4 GiB"}, {"Datenbank Hauptsystem", "TEILWEISE FEHLGESCHLAGEN", "22,0 MiB"}, }, }, { Title: "Übersprungene Aufträge", Columns: []string{"Auftrag", "Grund"}, EmptyNotice: "Kein Auftrag wurde übersprungen.", }, }, }, }, Notes: []string{"Die Größe nach Kompression wird nicht festgehalten."}, } return sampleReport } // TestCSVLeavesUnmeasuredValuesEmpty ist der wichtigste Test des Exports. // // Eine Null in einer Tabellenkalkulation wird summiert, gemittelt und in ein // Diagramm gezeichnet. Sie ueberlebt jede muendliche Erlaeuterung, mit der man // sie einordnen wollte — deshalb darf eine ungemessene Kennzahl niemals als // Zahl in der Datei landen. func TestCSVLeavesUnmeasuredValuesEmpty(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } csvContent := outputBuffer.String() for _, csvLine := range strings.Split(csvContent, "\n") { if !strings.HasPrefix(csvLine, "Erfolgsquote") { continue } if strings.Contains(csvLine, ",0,") || strings.HasSuffix(strings.TrimSpace(csvLine), ",0") { testInstance.Errorf("die ungemessene Erfolgsquote steht als Null in der Datei: %q", csvLine) } if !strings.Contains(csvLine, "nicht gemessen") { testInstance.Errorf("die Zeile nennt den Grund nicht: %q", csvLine) } return } testInstance.Error("die Zeile der Erfolgsquote fehlt in der Ausgabe") } // TestCSVWritesRawNumbersNotFormattedText prueft die Rechenbarkeit. // // Eine Tabellenkalkulation soll rechnen koennen. „1,4 GiB" ist Text und macht // jede Summe unmoeglich. func TestCSVWritesRawNumbersNotFormattedText(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } if !strings.Contains(outputBuffer.String(), "1503238553") { testInstance.Error("die Datenmenge steht nicht als Rohwert in der Datei") } } // TestCSVStartsWithByteOrderMark prueft die Lesbarkeit unter Windows. // // Ohne ihn zeigt Excel „Sicherungslÿufe" — bei einem Bericht mit deutschen // Beschriftungen also bei jeder Datei. func TestCSVStartsWithByteOrderMark(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } if !bytes.HasPrefix(outputBuffer.Bytes(), []byte{0xEF, 0xBB, 0xBF}) { testInstance.Error("der Datei fehlt die Byte-Order-Markierung") } } // TestCSVNamesEmptyTables prueft die Aussage einer leeren Tabelle. // // „Kein Auftrag wurde uebersprungen" ist eine Aussage, eine leere Flaeche ist // keine. func TestCSVNamesEmptyTables(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WriteCSV(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } if !strings.Contains(outputBuffer.String(), "Kein Auftrag wurde übersprungen.") { testInstance.Error("die leere Tabelle wird ohne Hinweis ausgegeben") } } // TestPDFHasValidStructure prueft, dass die Datei ein PDF ist. // // „Es kommen Bytes heraus" ist kein Nachweis. Geprueft werden Kopf, Endmarke, // Querverweistabelle und die Angabe der Objektzahl — genau die Bestandteile, // an denen ein Betrachter eine Datei zurueckweist. func TestPDFHasValidStructure(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WritePDF(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } pdfBytes := outputBuffer.Bytes() if !bytes.HasPrefix(pdfBytes, []byte("%PDF-1.4")) { testInstance.Error("der Kopf fehlt") } if !bytes.HasSuffix(bytes.TrimRight(pdfBytes, "\n"), []byte("%%EOF")) { testInstance.Error("die Endmarke fehlt") } for _, requiredPart := range []string{"/Type /Catalog", "/Type /Pages", "/Type /Page", "/BaseFont /Helvetica", "/Encoding /WinAnsiEncoding", "xref", "trailer", "startxref"} { if !bytes.Contains(pdfBytes, []byte(requiredPart)) { testInstance.Errorf("dem Dokument fehlt %q", requiredPart) } } } // TestPDFCrossReferenceOffsetsArePrecise prueft die Querverweistabelle. // // Sie ist der einzige Teil eines PDF, bei dem ein Fehler nicht auffaellt, // solange man die Datei nur ansieht: Ein falscher Offset bricht das Dokument // erst im Betrachter des Empfaengers — und dann heisst es, der Bericht sei // beschaedigt angekommen. func TestPDFCrossReferenceOffsetsArePrecise(testInstance *testing.T) { outputBuffer := &bytes.Buffer{} if writeError := WritePDF(outputBuffer, buildSampleReport()); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } pdfBytes := outputBuffer.Bytes() // Gesucht wird "\nxref\n", nicht "xref\n": Letzteres steckt auch in // "startxref", und LastIndex faende genau das — der Test praeglte dann eine // Tabelle mit null Eintraegen und bestuende immer. crossReferenceStart := bytes.LastIndex(pdfBytes, []byte("\nxref\n")) if crossReferenceStart < 0 { testInstance.Fatal("die Querverweistabelle fehlt") } crossReferenceStart++ // Jeder Eintrag verweist auf den Beginn eines Objekts. Steht dort nicht // „ 0 obj", zeigt der Verweis ins Leere. tableLines := strings.Split(string(pdfBytes[crossReferenceStart:]), "\n") checkedEntries := 0 for _, tableLine := range tableLines[2:] { if !strings.HasSuffix(tableLine, " 00000 n ") { continue } var objectOffset int if _, scanError := parseOffset(tableLine, &objectOffset); scanError != nil { testInstance.Fatalf("der Eintrag %q ist nicht lesbar: %v", tableLine, scanError) } if objectOffset >= len(pdfBytes) { testInstance.Fatalf("der Verweis %d liegt hinter dem Dateiende (%d Byte)", objectOffset, len(pdfBytes)) } remainder := pdfBytes[objectOffset:] if !bytes.Contains(remainder[:min(24, len(remainder))], []byte(" 0 obj")) { testInstance.Errorf("der Verweis %d zeigt nicht auf einen Objektbeginn, sondern auf %q", objectOffset, string(remainder[:min(24, len(remainder))])) } checkedEntries++ } if checkedEntries < 6 { testInstance.Errorf("nur %d Verweise geprüft; die Tabelle ist unvollständig", checkedEntries) } } // parseOffset liest den Byte-Offset eines Querverweiseintrags. func parseOffset(tableLine string, objectOffset *int) (int, error) { return sscanDecimal(strings.TrimSpace(strings.Split(tableLine, " ")[0]), objectOffset) } // sscanDecimal liest eine Dezimalzahl mit fuehrenden Nullen. func sscanDecimal(rawNumber string, target *int) (int, error) { parsedValue := 0 for _, digitRune := range rawNumber { if digitRune < '0' || digitRune > '9' { return 0, errNotANumber } parsedValue = parsedValue*10 + int(digitRune-'0') } *target = parsedValue return 1, nil } // errNotANumber meldet einen unlesbaren Eintrag. var errNotANumber = errorString("der Eintrag enthält keine Zahl") // errorString ist ein Fehler aus einer festen Zeichenkette. type errorString string // Error liefert den Fehlertext. func (message errorString) Error() string { return string(message) } // TestPDFEncodesGermanUmlauts prueft die Zeichenkodierung. // // Ohne Umwandlung nach WinAnsi zeigt der Betrachter „Sicherungsläufe" — und // zwar erst beim Empfaenger, nicht beim Erzeuger. func TestPDFEncodesGermanUmlauts(testInstance *testing.T) { umlautReport := buildSampleReport() umlautReport.Title = "Prüfbericht Größe" outputBuffer := &bytes.Buffer{} if writeError := WritePDF(outputBuffer, umlautReport); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } // „ü" ist in UTF-8 zwei Byte (0xC3 0xBC), in WinAnsi eines (0xFC). if bytes.Contains(outputBuffer.Bytes(), []byte{0xC3, 0xBC}) { testInstance.Error("das Dokument enthält UTF-8-Bytes statt WinAnsi") } if !bytes.Contains(outputBuffer.Bytes(), []byte{0xFC}) { testInstance.Error("der Umlaut fehlt im Dokument") } } // TestPDFBreaksIntoPages prueft den Seitenumbruch. // // Ein Monatsbericht ueber hundert Auftraege passt nicht auf eine Seite. Ohne // Umbruch schriebe der Satz unter den Seitenrand — und dort ist nichts zu // sehen, ohne dass eine Fehlermeldung darauf hinwiese. func TestPDFBreaksIntoPages(testInstance *testing.T) { longReport := buildSampleReport() manyRows := make([][]string, 0, 200) for rowIndex := range 200 { manyRows = append(manyRows, []string{ "Auftrag " + trimNumber(float64(rowIndex)), "erfolgreich", "1,0 GiB", }) } longReport.Sections[0].Tables[0].Rows = manyRows outputBuffer := &bytes.Buffer{} if writeError := WritePDF(outputBuffer, longReport); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } pageCount := bytes.Count(outputBuffer.Bytes(), []byte("/Type /Page ")) if pageCount < 3 { testInstance.Errorf("200 Zeilen ergaben %d Seiten; der Umbruch greift nicht", pageCount) } // Die letzte Zeile muss enthalten sein, sonst wurde still abgeschnitten. if !bytes.Contains(outputBuffer.Bytes(), []byte("Auftrag 199")) { testInstance.Error("die letzte Zeile fehlt im Dokument") } } // TestJSONKeepsMeasuredZero prueft, dass eine gemessene Null erhalten bleibt. // // Derselbe Fehler wie in Phase 13: Ein `omitempty` liesse einen gemessenen Wert // von null aus der Antwort verschwinden, und der Leser saehe „gemessen" ohne // Wert. func TestJSONKeepsMeasuredZero(testInstance *testing.T) { zeroReport := buildSampleReport() zeroReport.Sections[0].Metrics = []Metric{KnownMetric("Gescheiterte Läufe", 0, UnitCount)} outputBuffer := &bytes.Buffer{} if writeError := WriteJSON(outputBuffer, zeroReport); writeError != nil { testInstance.Fatalf("die ausgabe schlug fehl: %v", writeError) } var decodedReport map[string]any if decodeError := json.Unmarshal(outputBuffer.Bytes(), &decodedReport); decodeError != nil { testInstance.Fatalf("die Ausgabe ist kein gültiges JSON: %v", decodeError) } firstSection := decodedReport["sections"].([]any)[0].(map[string]any) firstMetric := firstSection["metrics"].([]any)[0].(map[string]any) if _, hasValue := firstMetric["value"]; !hasValue { testInstance.Error("die gemessene Null fehlt in der Ausgabe") } if firstMetric["is_known"] != true { testInstance.Error("die Kennzahl gilt nicht als gemessen") } } // TestFileNameIsSafe prueft die Bereinigung des Dateinamens. func TestFileNameIsSafe(testInstance *testing.T) { unsafeName := SanitizeFileName("bericht\"\r\nX-Injected: 1.pdf") for _, forbiddenRune := range []string{"\"", "\r", "\n", " ", ":"} { if strings.Contains(unsafeName, forbiddenRune) { testInstance.Errorf("der Name enthält %q: %q", forbiddenRune, unsafeName) } } }