// 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() { _, _ = pool.Exec(context.Background(), `DELETE FROM mail_rules WHERE tenant_slug LIKE 'mandant-imp03-%'`) }) 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)) } }