// Package ingestiontestgate implementiert QA-02: das Prüfgate für // Ingestion & Import — spiegelt das Muster aus mail/internal/qagate // (QA-03) und mail/internal/importtestgate (IMP-09), hier bezogen auf // ALLE Ingestion-/Import-Pakete zusammen (Protokoll-Server, Fehler- // behandlung/Logging, Import-Pfade). package ingestiontestgate import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // IngestionAndImportPackages sind alle Pakete, deren Testsuiten das // Gate ausführt (Akzeptanzkriterium 1) — abgeleitet aus QA-02s // dependsOn-Liste: ING-10 (Protokoll-Server + MIME + Folder-State), // ING-07 (Fehlerbehandlung, protoguard), ING-08 (Logging, protolog), // IMP-04/IMP-09 (imapimport), IMP-05 (hotfolder), IMP-06 (virusscan), // IMP-07 (mailboxconfig), IMP-08 (syncalert). attachments/mailrules // gehören ebenfalls zum von IMP-09 geprüften Importpfad. var IngestionAndImportPackages = []string{ "./internal/imap/...", "./internal/pop3/...", "./internal/smtp/...", "./internal/protoguard/...", "./internal/protolog/...", "./internal/mimeparse/...", "./internal/folderstate/...", "./internal/imapimport/...", "./internal/attachments/...", "./internal/mailrules/...", "./internal/hotfolder/...", "./internal/virusscan/...", "./internal/mailboxconfig/...", "./internal/syncalert/...", } // TestSuiteResult ist das Ergebnis eines `go test`-Laufs über // IngestionAndImportPackages. type TestSuiteResult struct { Passed bool Output string } // RunTestSuites führt `go test` über IngestionAndImportPackages im // angegebenen Modulverzeichnis aus (Akzeptanzkriterium 1). Umgebungs- // variablen (TEST_TENANT_DSN u. Ä.) werden unverändert vom aufrufenden // Prozess vererbt — das Gate setzt selbst keine Zugangsdaten. func RunTestSuites(ctx context.Context, moduleDir string) (TestSuiteResult, error) { // -p 1: Pakete NACHEINANDER statt parallel — mehrere gleichzeitige // go-test-Binaries würden sich bei den echten QA-07-Lasttests // (imap/pop3/smtp) gegenseitig CPU-Kontingent wegnehmen und so // Latenz-Zielwerte durch reine Testhost-Überlastung verfehlen // lassen, nicht durch einen echten Regressionsfehler. args := append([]string{"test", "-count=1", "-p", "1"}, IngestionAndImportPackages...) cmd := exec.CommandContext(ctx, "go", args...) cmd.Dir = moduleDir var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out err := cmd.Run() if err != nil { if _, isExitErr := err.(*exec.ExitError); !isExitErr { return TestSuiteResult{}, fmt.Errorf("ingestiontestgate: go test ausführen: %w", err) } } return TestSuiteResult{Passed: err == nil, Output: out.String()}, nil } // KnownErrorPoint ist ein bekannter, historisch dokumentierter // Fehlerpunkt (Akzeptanzkriterium 2), der durch mindestens einen // benannten Regressionstest abgedeckt sein muss. type KnownErrorPoint struct { Name string // z. B. "Header-Injection" PackageRelDir string // relativ zu moduleDir, z. B. "internal/mailer" TestNameMarker string // Teilzeichenkette, die im Testfunktionsnamen vorkommen muss Rationale string // kurze Begründung, warum dieser Punkt bekannt kritisch ist } // knownErrorPoints sind die drei in QA-02 Akzeptanzkriterium 2 namentlich // geforderten Fehlerpunkte. var knownErrorPoints = []KnownErrorPoint{ { Name: "Header-Injection", PackageRelDir: "internal/mailer", TestNameMarker: "CRLF", Rationale: "known-issues-archivmail.md #1: From/To/Subject per Stringkonkatenation ohne CRLF-Prüfung", }, { Name: "Anhang-Limit", PackageRelDir: "internal/mimeparse", TestNameMarker: "OversizedAttachment", Rationale: "known-issues-archivmail.md #3: Anhänge ohne Größenlimit gelesen (Speicherbombe)", }, { Name: "UIDVALIDITY", PackageRelDir: "internal/folderstate", TestNameMarker: "UIDValidity", Rationale: "ING-01/repos-analyse-mail-reuse.md: archivmail brach Resync bei UIDVALIDITY=0 nicht-konformer Server", }, } // KnownErrorPointResult ist das Ergebnis der Prüfung EINES bekannten // Fehlerpunkts. type KnownErrorPointResult struct { Point KnownErrorPoint Covered bool FoundInFile string TestName string } // KnownErrorPointsResult fasst die Stichprobenprüfung aller bekannten // Fehlerpunkte zusammen (Akzeptanzkriterium 2, Pflichtprüfung 2: // "mindestens ein Regressionstest je bekanntem Fehlerpunkt"). type KnownErrorPointsResult struct { Passed bool Results []KnownErrorPointResult } // ScanForKnownErrorPointTests prüft für jeden bekannten Fehlerpunkt, ob // im angegebenen Paketverzeichnis mindestens eine _test.go-Datei eine // Testfunktion enthält, deren Name den TestNameMarker enthält. func ScanForKnownErrorPointTests(moduleDir string) (KnownErrorPointsResult, error) { out := KnownErrorPointsResult{Passed: true} for _, point := range knownErrorPoints { dir := filepath.Join(moduleDir, point.PackageRelDir) entries, err := os.ReadDir(dir) if err != nil { return KnownErrorPointsResult{}, fmt.Errorf("ingestiontestgate: %s lesen: %w", dir, err) } res := KnownErrorPointResult{Point: point} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") { continue } content, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { return KnownErrorPointsResult{}, fmt.Errorf("ingestiontestgate: %s lesen: %w", e.Name(), err) } if testName, ok := findTestFuncContaining(string(content), point.TestNameMarker); ok { res.Covered = true res.FoundInFile = e.Name() res.TestName = testName break } } if !res.Covered { out.Passed = false } out.Results = append(out.Results, res) } return out, nil } // findTestFuncContaining sucht die erste "func TestXxx(" -Zeile, deren // Funktionsname marker enthält. func findTestFuncContaining(source, marker string) (string, bool) { for _, line := range strings.Split(source, "\n") { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "func Test") { continue } parenIdx := strings.Index(trimmed, "(") if parenIdx < 0 { continue } name := strings.TrimPrefix(trimmed[:parenIdx], "func ") if strings.Contains(name, marker) { return name, true } } return "", false } // GateResult fasst ein vollständiges QA-02-Gate-Ergebnis zusammen // (Akzeptanzkriterium 3: dokumentiert, mit Zeitstempel). type GateResult struct { Timestamp time.Time TestSuite TestSuiteResult KnownErrorPoints KnownErrorPointsResult } // Passed ist true, wenn ALLE Kriterien erfüllt sind. func (r GateResult) Passed() bool { return r.TestSuite.Passed && r.KnownErrorPoints.Passed } // Run führt das vollständige Gate aus. func Run(ctx context.Context, moduleDir string) (GateResult, error) { testResult, err := RunTestSuites(ctx, moduleDir) if err != nil { return GateResult{}, err } errorPointsResult, err := ScanForKnownErrorPointTests(moduleDir) if err != nil { return GateResult{}, err } return GateResult{ Timestamp: time.Now().UTC(), TestSuite: testResult, KnownErrorPoints: errorPointsResult, }, nil } // Report erzeugt den dokumentierten, zeitgestempelten Gate-Bericht // (Akzeptanzkriterium 3). func (r GateResult) Report() string { status := "BESTANDEN" if !r.Passed() { status = "FEHLGESCHLAGEN" } var b strings.Builder fmt.Fprintf(&b, "# QA-02 Gate-Ergebnis: %s\n\n", status) fmt.Fprintf(&b, "Zeitstempel (UTC): %s\n\n", r.Timestamp.Format(time.RFC3339)) fmt.Fprintf(&b, "## Testsuiten (Ingestion & Import, %d Pakete)\n\nBestanden: %v\n\n", len(IngestionAndImportPackages), r.TestSuite.Passed) fmt.Fprintf(&b, "## Bekannte Fehlerpunkte — Regressionstest-Stichprobe\n\n") for _, res := range r.KnownErrorPoints.Results { fmt.Fprintf(&b, "- %s (%s): abgedeckt=%v", res.Point.Name, res.Point.Rationale, res.Covered) if res.Covered { fmt.Fprintf(&b, " — %s in %s/%s\n", res.TestName, res.Point.PackageRelDir, res.FoundInFile) } else { fmt.Fprintf(&b, "\n") } } return b.String() }