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
193 lines
6.7 KiB
Go
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))
|
|
}
|
|
}
|