Files
nexarch/mail/internal/mailrules/engine_test.go
T
sysopsandClaude Sonnet 5 089d7e6d96 IMP-03: e-mail-regeln-zuordnung-tags-klassifizierung
Regelwerk für automatische Zuordnung, Verschlagwortung und
Klassifizierung importierter E-Mails nach Absender, Betreff, Postfach
und Anhangstyp.

- store.go: Postgres-Store für Regeln (Absender-/Betreff-/Postfach-/
  Anhangstyp-Muster als reguläre Ausdrücke, Category einwertig, Tag
  mehrwertig, Priority — niedrigere Zahl = höhere Priorität).
- engine.go: Engine.Evaluate wertet Regeln in Prioritätsreihenfolge aus,
  "first match wins" für Category, alle zutreffenden Regeln tragen zu
  Tags bei. Muster werden beim Erzeugen der Engine einmal kompiliert.
- Bewusst keine Funktion zum rückwirkenden Neuklassifizieren bestehender
  Nachrichten — nur explizite RunOnce-artige Neuauswertung wirkt.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-03-PRUEFPROTOKOLL.md):
1. TestEvaluate_ConflictingRulesRespectDocumentedPriority: höherpriorisierte
   Regel gewinnt real bei widersprüchlichen Kategorien.
2. TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult: bereits
   erfasstes Ergebnis bleibt real unverändert nach neuer Regel.
3. TestEvaluate_TwentyPlusRulesStayPerformant: 31 Regeln, 2,64µs/Auswertung.

Kein Umbau: kein bestehendes Paket angefasst.

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

188 lines
6.4 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() {
_, _ = 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))
}
}