// Integrationstest (IMP-08): echte Postgres-Instanz, folgt derselben // Testhost-Konvention wie mail/internal/dedup/folderstate/imapimport — // TEST_TENANT_DSN. package syncalert import ( "context" "fmt" "os" "sync" "testing" "github.com/jackc/pgx/v5/pgxpool" ) // fakeDispatcher zeichnet jeden Enqueue-Aufruf auf — echte HTTP- // Anbindung ist Sache von dispatcher_http_test.go, hier wird die // Eskalationslogik isoliert geprüft (gleiche Konvention wie // fakeAuthenticator/fakeKEKProvider in anderen Mail-Paketen). type fakeDispatcher struct { mu sync.Mutex calls []map[string]any } func (f *fakeDispatcher) Enqueue(_ context.Context, channel, recipient string, payload map[string]any) (string, error) { f.mu.Lock() defer f.mu.Unlock() call := map[string]any{"channel": channel, "recipient": recipient} for k, v := range payload { call[k] = v } f.calls = append(f.calls, call) return fmt.Sprintf("notif-%d", len(f.calls)), nil } func (f *fakeDispatcher) count() int { f.mu.Lock() defer f.mu.Unlock() return len(f.calls) } func setupMonitor(t *testing.T, dispatcher NotificationDispatcher) *Monitor { 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() }) monitor := NewMonitor(pool, dispatcher).WithThreshold(3) if err := monitor.EnsureSchema(ctx); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM mail_sync_alert_state WHERE tenant_slug LIKE 'mandant-imp08-%'`) }) return monitor } // TestRecordFailure_NConsecutiveFailuresTriggerExactlyOneNotification // ist die geforderte Pflichtprüfung 1: N aufeinanderfolgende // Fehlschläge lösen genau eine Benachrichtigung aus, keine Spam-Flut. func TestRecordFailure_NConsecutiveFailuresTriggerExactlyOneNotification(t *testing.T) { dispatcher := &fakeDispatcher{} monitor := setupMonitor(t, dispatcher) ctx := context.Background() tenant := "mandant-imp08-schwelle" // Schwelle ist 3 — die ersten 2 Fehlschläge dürfen NICHTS auslösen. for i := 0; i < 2; i++ { if err := monitor.RecordFailure(ctx, tenant, "INBOX", "verbindung abgelehnt"); err != nil { t.Fatalf("recordfailure %d: %v", i, err) } } if dispatcher.count() != 0 { t.Fatalf("erwartete 0 benachrichtigungen vor erreichen der schwelle, habe %d", dispatcher.count()) } // Dritter Fehlschlag erreicht die Schwelle — GENAU EINE Benachrichtigung. if err := monitor.RecordFailure(ctx, tenant, "INBOX", "verbindung abgelehnt"); err != nil { t.Fatalf("recordfailure 3: %v", err) } if dispatcher.count() != 1 { t.Fatalf("erwartete genau 1 benachrichtigung bei erreichen der schwelle, habe %d", dispatcher.count()) } // Weitere Fehlschläge DANACH dürfen KEINE zusätzliche Benachrichtigung // auslösen (kein Einzel-Alarm pro Fehlversuch, keine Spam-Flut). for i := 0; i < 5; i++ { if err := monitor.RecordFailure(ctx, tenant, "INBOX", "verbindung abgelehnt"); err != nil { t.Fatalf("weiterer fehlschlag %d: %v", i, err) } } if dispatcher.count() != 1 { t.Fatalf("erwartete weiterhin genau 1 benachrichtigung nach 5 weiteren fehlschlägen, habe %d", dispatcher.count()) } } // TestRecordSuccess_EndsAlertStateVerifiably ist die geforderte // Pflichtprüfung 2: erfolgreicher Lauf nach Ausfall beendet den // Alarmzustand nachvollziehbar. func TestRecordSuccess_EndsAlertStateVerifiably(t *testing.T) { dispatcher := &fakeDispatcher{} monitor := setupMonitor(t, dispatcher) ctx := context.Background() tenant := "mandant-imp08-reset" for i := 0; i < 3; i++ { if err := monitor.RecordFailure(ctx, tenant, "INBOX", "timeout"); err != nil { t.Fatalf("recordfailure %d: %v", i, err) } } if dispatcher.count() != 1 { t.Fatalf("erwartete 1 benachrichtigung nach 3 fehlschlägen, habe %d", dispatcher.count()) } if err := monitor.RecordSuccess(ctx, tenant, "INBOX"); err != nil { t.Fatalf("recordsuccess: %v", err) } // Nachvollziehbar zurückgesetzt: der NÄCHSTE Fehlschlags-Zyklus muss // real wieder bei 0 beginnen und erneut die volle Schwelle // durchlaufen, bevor eine ZWEITE Benachrichtigung ausgelöst wird. for i := 0; i < 2; i++ { if err := monitor.RecordFailure(ctx, tenant, "INBOX", "timeout"); err != nil { t.Fatalf("recordfailure nach reset %d: %v", i, err) } } if dispatcher.count() != 1 { t.Fatalf("erwartete weiterhin nur 1 benachrichtigung (schwelle nach reset noch nicht erreicht), habe %d", dispatcher.count()) } if err := monitor.RecordFailure(ctx, tenant, "INBOX", "timeout"); err != nil { t.Fatalf("dritter fehlschlag nach reset: %v", err) } if dispatcher.count() != 2 { t.Fatalf("erwartete 2. benachrichtigung nach erneutem erreichen der schwelle, habe %d", dispatcher.count()) } } // TestRecordFailure_MultipleAffectedMailboxesStayIsolated ist die // geforderte Pflichtprüfung 3: Test mit mehreren betroffenen // Postfächern gleichzeitig bleibt übersichtlich (korrekt isoliert). func TestRecordFailure_MultipleAffectedMailboxesStayIsolated(t *testing.T) { dispatcher := &fakeDispatcher{} monitor := setupMonitor(t, dispatcher) ctx := context.Background() tenant := "mandant-imp08-mehrere" mailboxes := []string{"INBOX", "Archiv", "Vertrieb"} var wg sync.WaitGroup for _, mailbox := range mailboxes { wg.Add(1) go func(mb string) { defer wg.Done() for i := 0; i < 3; i++ { _ = monitor.RecordFailure(ctx, tenant, mb, "gleichzeitiger ausfall") } }(mailbox) } wg.Wait() if dispatcher.count() != len(mailboxes) { t.Fatalf("erwartete genau 1 benachrichtigung je betroffenem postfach (%d), habe %d", len(mailboxes), dispatcher.count()) } seenMailboxes := map[string]bool{} dispatcher.mu.Lock() for _, call := range dispatcher.calls { mb, _ := call["mailbox"].(string) if seenMailboxes[mb] { t.Fatalf("postfach %q hat mehr als eine benachrichtigung erhalten", mb) } seenMailboxes[mb] = true } dispatcher.mu.Unlock() for _, mb := range mailboxes { if !seenMailboxes[mb] { t.Fatalf("postfach %q fehlt unter den benachrichtigten, habe: %v", mb, seenMailboxes) } } } // TestRecordFailure_NotificationContainsRequiredFields deckt // Akzeptanzkriterium 2 ab: Benachrichtigung enthält Postfach, // Fehlerursache und Zeitpunkt des letzten erfolgreichen Abrufs. func TestRecordFailure_NotificationContainsRequiredFields(t *testing.T) { dispatcher := &fakeDispatcher{} monitor := setupMonitor(t, dispatcher) ctx := context.Background() tenant := "mandant-imp08-inhalt" if err := monitor.RecordSuccess(ctx, tenant, "INBOX"); err != nil { t.Fatalf("initialer erfolg: %v", err) } for i := 0; i < 3; i++ { if err := monitor.RecordFailure(ctx, tenant, "INBOX", "authentifizierung fehlgeschlagen"); err != nil { t.Fatalf("recordfailure %d: %v", i, err) } } if dispatcher.count() != 1 { t.Fatalf("erwartete 1 benachrichtigung, habe %d", dispatcher.count()) } call := dispatcher.calls[0] if call["mailbox"] != "INBOX" { t.Fatalf("erwartete postfach 'INBOX' in der benachrichtigung, habe: %v", call["mailbox"]) } if call["reason"] != "authentifizierung fehlgeschlagen" { t.Fatalf("erwartete fehlerursache in der benachrichtigung, habe: %v", call["reason"]) } lastSuccess, _ := call["last_successful_sync"].(string) if lastSuccess == "" { t.Fatal("erwartete zeitpunkt des letzten erfolgreichen abrufs in der benachrichtigung") } }