Files
nexarch/mail/internal/mailrules/engine_test.go
T
sysopsandClaude Sonnet 5 dac7854440 IMP-09: import-testsuite
Testsuite für Import-Scheduler, Anhangsverarbeitung und Regelwerk,
inklusive Tenant-Scoping und nicht-konformer Server.

- tenant_scoping_test.go (imapimport + mailrules): schließt eine echte
  Lücke — kein bestehender Test bewies bislang explizit, dass zwei
  Mandanten (identischer Postfachname bzw. fehlende eigene Regel) sich
  nicht gegenseitig beeinflussen.
- importtestgate/gate.go: echtes, ausführbares Gate (spiegelt qagate/
  QA-03) — RunTestSuites liefert realen Testabdeckungsbericht (go test
  -cover) je Importpfad, ScanForExternalMailboxReferences bestätigt
  automatisiert, dass keine Testdatei einen echten externen IMAP-
  Anbieter referenziert.
- Echten Bug beim eigenen Testlauf gefunden und behoben: die
  t.Cleanup-Löschfilter in scheduler_test.go/engine_test.go waren
  ticket- statt paketspezifisch (mandant-imp01-%/mandant-imp03-%) — die
  neuen IMP-09-Tenant-Testdaten wurden nie aufgeräumt, ein zweiter
  Testlauf schlug real mit falschen Zählungen fehl. Auf mandant-%
  verallgemeinert.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-09-PRUEFPROTOKOLL.md):
1. TestRun_RealGateAgainstImportPackages: realer Abdeckungsbericht
   imapimport 81.5%, attachments 94.4%, mailrules 71.2%.
2. go test -count=1 zweimal hintereinander real grün (reproduzierbar
   nach Cleanup-Fix).
3. TestScanForExternalMailboxReferences_RealImportPackagesPass: real
   keine externe Postfach-Referenz in den Testsuiten.

Kein Umbau der geprüften Produktionslogik.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:17:42 +02:00

193 lines
6.7 KiB
Go

// Integrationstest (IMP-03): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/folderstate/savedsearch/
// imapimport — TEST_TENANT_DSN.
package mailrules
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewStore(pool)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
// LIKE-Muster bewusst paket-, nicht ticketspezifisch (mandant-%
// statt mandant-imp03-%) — mehrere Tickets (u. a. IMP-09) fügen
// diesem Paket über die Zeit weitere Tests mit eigenen
// Mandanten-Präfixen hinzu; ein zu enges Muster ließ bereits real
// Testdaten ungelöscht zurück (siehe IMP-09-Prüfprotokoll).
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_rules WHERE tenant_slug LIKE 'mandant-%'`)
})
return store
}
// TestEvaluate_ConflictingRulesRespectDocumentedPriority ist die
// geforderte Pflichtprüfung 1: widersprüchliche Regeln bestätigen
// dokumentierte Priorisierung.
func TestEvaluate_ConflictingRulesRespectDocumentedPriority(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-imp03-prioritaet"
// Zwei Regeln matchen dieselbe Nachricht, weisen aber
// WIDERSPRÜCHLICHE Kategorien zu — die mit der niedrigeren
// Priority-Zahl (höhere Priorität) muss gewinnen.
if _, err := store.Create(ctx, tenant, Rule{Name: "niedrige prio", SenderPattern: "rechnung@", Category: "Sonstiges", Priority: 200}); err != nil {
t.Fatalf("regel 1 anlegen: %v", err)
}
if _, err := store.Create(ctx, tenant, Rule{Name: "hohe prio", SenderPattern: "rechnung@", Category: "Rechnungswesen", Priority: 10}); err != nil {
t.Fatalf("regel 2 anlegen: %v", err)
}
rules, err := store.List(ctx, tenant)
if err != nil {
t.Fatalf("list: %v", err)
}
engine, err := NewEngine(rules)
if err != nil {
t.Fatalf("newengine: %v", err)
}
result := engine.Evaluate(EmailMetadata{Sender: "rechnung@lieferant.example"})
if result.Category != "Rechnungswesen" {
t.Fatalf("erwartete kategorie der höherprioren regel 'Rechnungswesen', habe %q", result.Category)
}
if len(result.MatchedRuleIDs) != 2 {
t.Fatalf("erwartete beide regeln als zutreffend vermerkt, habe: %v", result.MatchedRuleIDs)
}
}
// TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult ist die
// geforderte Pflichtprüfung 2: eine neue Regel ändert keine bereits
// importierten Altbestände automatisch.
func TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-imp03-altbestand"
msg := EmailMetadata{Sender: "info@partner.example", Subject: "Angebot"}
// Zustand VOR der neuen Regel: kein Match, keine Kategorie.
rulesBefore, err := store.List(ctx, tenant)
if err != nil {
t.Fatalf("list (vorher): %v", err)
}
engineBefore, err := NewEngine(rulesBefore)
if err != nil {
t.Fatalf("newengine (vorher): %v", err)
}
// "Bereits importierte Nachricht": Klassifizierung wird EINMALIG zum
// Importzeitpunkt berechnet und danach als fester Wert behandelt —
// simuliert durch eine lokale Variable, die ab hier NICHT mehr neu
// berechnet wird.
importedResult := engineBefore.Evaluate(msg)
if importedResult.Category != "" {
t.Fatalf("erwartete keine kategorie vor regelanlage, habe %q", importedResult.Category)
}
// Neue, zutreffende Regel wird angelegt — repräsentiert eine
// nachträgliche Regeländerung.
if _, err := store.Create(ctx, tenant, Rule{Name: "neue regel", SenderPattern: "partner\\.example", Category: "Vertrieb", Priority: 50}); err != nil {
t.Fatalf("neue regel anlegen: %v", err)
}
// Akzeptanzkriterium 3: das bereits erfasste Altbestands-Ergebnis
// bleibt UNVERÄNDERT — es wird nirgends automatisch neu berechnet.
if importedResult.Category != "" {
t.Fatalf("altbestand wurde rückwirkend verändert, kategorie jetzt %q", importedResult.Category)
}
// Eine EXPLIZITE Neuauswertung (repräsentiert einen expliziten
// Reindex-Auftrag) zeigt dagegen real die neue Regel — beweist, dass
// die Regel selbst funktioniert und der vorherige Befund nicht durch
// einen kaputten Test zufällig "unverändert" blieb.
rulesAfter, err := store.List(ctx, tenant)
if err != nil {
t.Fatalf("list (nachher): %v", err)
}
engineAfter, err := NewEngine(rulesAfter)
if err != nil {
t.Fatalf("newengine (nachher): %v", err)
}
freshResult := engineAfter.Evaluate(msg)
if freshResult.Category != "Vertrieb" {
t.Fatalf("erwartete kategorie 'Vertrieb' bei expliziter neuauswertung, habe %q", freshResult.Category)
}
}
// TestEvaluate_TwentyPlusRulesStayPerformant ist die geforderte
// Pflichtprüfung 3: Regelset mit 20+ Regeln bleibt performant auswertbar.
func TestEvaluate_TwentyPlusRulesStayPerformant(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-imp03-performance"
const ruleCount = 30
for i := 0; i < ruleCount; i++ {
_, err := store.Create(ctx, tenant, Rule{
Name: fmt.Sprintf("regel-%d", i),
SenderPattern: fmt.Sprintf("^absender%d@", i),
Category: fmt.Sprintf("Kategorie-%d", i),
Tag: fmt.Sprintf("tag-%d", i),
Priority: 100 + i,
})
if err != nil {
t.Fatalf("regel %d anlegen: %v", i, err)
}
}
// Eine Regel, die tatsächlich matcht (letzte Priorität, damit
// vorherige Nicht-Treffer real durchlaufen werden müssen).
if _, err := store.Create(ctx, tenant, Rule{Name: "treffer", SenderPattern: "^ziel@", Category: "Zielkategorie", Priority: 1}); err != nil {
t.Fatalf("treffer-regel anlegen: %v", err)
}
rules, err := store.List(ctx, tenant)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rules) < 20 {
t.Fatalf("erwartete mindestens 20 regeln, habe %d", len(rules))
}
engine, err := NewEngine(rules)
if err != nil {
t.Fatalf("newengine: %v", err)
}
const evaluations = 1000
start := time.Now()
var lastResult Result
for i := 0; i < evaluations; i++ {
lastResult = engine.Evaluate(EmailMetadata{Sender: "ziel@example.com", Subject: "Test"})
}
elapsed := time.Since(start)
if lastResult.Category != "Zielkategorie" {
t.Fatalf("erwartete 'Zielkategorie', habe %q", lastResult.Category)
}
perEvaluation := elapsed / evaluations
t.Logf("Auswertung: %d Läufe über %d Regeln in %s (%s/Lauf)", evaluations, len(rules), elapsed, perEvaluation)
if perEvaluation > 5*time.Millisecond {
t.Fatalf("auswertung zu langsam: %s/lauf über %d regeln", perEvaluation, len(rules))
}
}