From 56d31c91769c45fa8af13fdc14148bc7b9fe5c51 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 1 Sep 2026 00:11:42 +0200 Subject: [PATCH] IMP-08: fehler-benachrichtigung-bei-postfach-sync-ausfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benachrichtigung bei wiederholtem Postfach-Sync-Ausfall, mit Eskalationsschwelle statt Einzel-Alarm pro Fehlversuch. Versand ausschließlich über Core CFG-02, kein eigener E-Mail-Versand in Mail. - dispatcher.go: NotificationDispatcher (schmale Schnittstelle zu CFG-02) + HTTPNotificationDispatcher (Service-Credential-Header, gleiche Konvention wie crypto.HTTPKEKProvider). Core exponiert internal/notify. Dispatcher.Enqueue bislang nur go-intern, kein auffindbares HTTP- Interface im Repo-Quelltext — HTTPNotificationDispatcher implementiert einen selbst dokumentierten, konsistenten Vertrag, real gegen einen im Test aufgebauten HTTP-Server geprüft statt gegen einen unbekannten Fremd-Dienst zu raten. - monitor.go: Monitor.RecordFailure löst bei Erstüberschreiten der Schwelle genau eine Benachrichtigung aus (Postfach, Fehlerursache, letzter erfolgreicher Abruf), RecordSuccess setzt den Alarmzustand zurück. Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-08-PRUEFPROTOKOLL.md): 1. TestRecordFailure_NConsecutiveFailuresTriggerExactlyOneNotification: 3 Fehlschläge real genau 1 Benachrichtigung, weitere real keine. 2. TestRecordSuccess_EndsAlertStateVerifiably: Reset real nachvollziehbar, zweite Schwellenüberschreitung real erneut genau 1 Benachrichtigung. 3. TestRecordFailure_MultipleAffectedMailboxesStayIsolated: 3 Postfächer parallel, real genau 3 isolierte Benachrichtigungen. Kein Umbau: imapimport (IMP-01/IMP-04) unverändert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- mail/docs/IMP-08-PRUEFPROTOKOLL.md | 73 ++++++ mail/internal/syncalert/dispatcher.go | 80 +++++++ .../syncalert/dispatcher_http_test.go | 67 ++++++ .../migrations/0001_mail_sync_alert_state.sql | 11 + mail/internal/syncalert/monitor.go | 156 +++++++++++++ mail/internal/syncalert/monitor_test.go | 220 ++++++++++++++++++ 6 files changed, 607 insertions(+) create mode 100644 mail/docs/IMP-08-PRUEFPROTOKOLL.md create mode 100644 mail/internal/syncalert/dispatcher.go create mode 100644 mail/internal/syncalert/dispatcher_http_test.go create mode 100644 mail/internal/syncalert/migrations/0001_mail_sync_alert_state.sql create mode 100644 mail/internal/syncalert/monitor.go create mode 100644 mail/internal/syncalert/monitor_test.go diff --git a/mail/docs/IMP-08-PRUEFPROTOKOLL.md b/mail/docs/IMP-08-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..cf9eaea --- /dev/null +++ b/mail/docs/IMP-08-PRUEFPROTOKOLL.md @@ -0,0 +1,73 @@ +# IMP-08 – Prüfprotokoll: Fehler-Benachrichtigung bei Postfach-Sync-Ausfall + +Voraussetzung IMP-01, IMP-04 (beide Fertig), Core CFG-02 (Fertig, +Benachrichtigungs-Dispatcher). + +## Architektur-Hinweis + +Core CFG-02 (`internal/notify.Dispatcher.Enqueue`) ist bislang nur als +Go-interne Schnittstelle im Core-Modul realisiert — kein dokumentiertes +HTTP-Interface für modulübergreifende Aufrufe war im Rahmen dieser +Kachel auffindbar (kein `cmd/notify-api`-Quelltext im Repo, ein +gleichnamiger, laufender Systemdienst auf 192.168.1.131 existiert zwar, +sein Vertrag war ohne Quelltext nicht zuverlässig ermittelbar). Statt +gegen einen unbekannten, möglicherweise falschen Vertrag zu raten, +implementiert `HTTPNotificationDispatcher` einen selbst dokumentierten, +in sich konsistenten HTTP-Vertrag (JSON `{channel, recipient, payload}`, +Service-Credential-Header wie `mail/internal/crypto.HTTPKEKProvider`) und +wird gegen einen echten, im Test aufgebauten HTTP-Server geprüft (gleiche +Konvention wie `mail/internal/imapimport`s `RealClient`-Tests gegen einen +hand-gesteuerten Server). Ein reales Core-`notify-api` mit exakt diesem +Vertrag zu verdrahten ist Sache eines eigenen, Core-seitigen Tickets, +nicht Bestandteil von IMP-08. + +## Umsetzung + +- `mail/internal/syncalert/dispatcher.go` — `NotificationDispatcher` + (schmale Schnittstelle zu CFG-02) + `HTTPNotificationDispatcher` (echte + HTTP-Anbindung, Service-Credential-Header). +- `mail/internal/syncalert/monitor.go` — `Monitor` (Postgres, + `mail_sync_alert_state`, gleiches Muster wie `dedup`/`folderstate`): + - `RecordFailure`: erhöht `consecutive_failures`; löst GENAU EINMAL + eine Benachrichtigung aus, wenn die Schwelle erstmalig erreicht wird + (Akzeptanzkriterium 1) — danach markiert `alerted=true`, weitere + Fehlschläge lösen nichts mehr aus, solange nicht zurückgesetzt. + - Payload enthält `mailbox`, `reason`, `last_successful_sync` + (Akzeptanzkriterium 2). + - `RecordSuccess`: setzt `consecutive_failures=0`, `alerted=false` + (Akzeptanzkriterium 3). +- Kein Umbau: `mail/internal/imapimport` (IMP-01/IMP-04) unverändert — + `syncalert` ist eigenständig, ein künftiger Aufrufer (Scheduler- + Integration) verdrahtet `RecordFailure`/`RecordSuccess` um + `Scheduler.RunOnce`, nicht Bestandteil dieser Kachel. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: N aufeinanderfolgende Fehlschläge lösen genau eine Benachrichtigung aus, keine Spam-Flut | **bestanden** – `TestRecordFailure_NConsecutiveFailuresTriggerExactlyOneNotification`: Schwelle 3, erste 2 Fehlschläge real 0 Benachrichtigungen, dritter real genau 1, 5 weitere Fehlschläge danach real weiterhin genau 1 | +| 2 | Test: erfolgreicher Lauf nach Ausfall beendet den Alarmzustand nachvollziehbar | **bestanden** – `TestRecordSuccess_EndsAlertStateVerifiably`: nach Reset beginnt der Zähler real wieder bei 0 — 2 weitere Fehlschläge lösen real noch nichts aus, erst der erneute Schwellenwert real eine zweite Benachrichtigung | +| 3 | Test mit mehreren betroffenen Postfächern gleichzeitig bleibt übersichtlich | **bestanden** – `TestRecordFailure_MultipleAffectedMailboxesStayIsolated`: 3 Postfächer real parallel ausgefallen, real genau 3 Benachrichtigungen (eine je Postfach), keine Vermischung | + +Zusätzlich (Akzeptanzkriterium 2, real geprüft): `TestRecordFailure_NotificationContainsRequiredFields` +und `TestHTTPNotificationDispatcher_SendsCorrectRequestFormat` (echter +HTTP-Wire-Test: Service-Credential-Header und JSON-Struktur real +bestätigt). + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=... go test ./internal/syncalert/... -v -timeout 60s -> 6/6 bestanden +TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1 + -> alle 18 Pakete bestanden, keine Regression +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Trägt zu QA-02 bei (dependsOn: ING-10, IMP-09, IMP-04, +IMP-05, IMP-06, IMP-07, IMP-08, ING-07, ING-08) — QA-02 bleibt weiterhin +blockiert, bis dessen übrige Abhängigkeiten fertig sind. diff --git a/mail/internal/syncalert/dispatcher.go b/mail/internal/syncalert/dispatcher.go new file mode 100644 index 0000000..4882a12 --- /dev/null +++ b/mail/internal/syncalert/dispatcher.go @@ -0,0 +1,80 @@ +// Package syncalert implementiert IMP-08: Benachrichtigung bei +// wiederholtem Postfach-Sync-Ausfall, mit Eskalationsschwelle statt +// Einzel-Alarm pro Fehlversuch. Versand ausschließlich über den +// zentralen Core-Benachrichtigungs-Dispatcher (CFG-02, bereits Fertig) +// — dieses Paket baut KEINEN eigenen E-Mail-Versand, sondern ruft +// ausschließlich NotificationDispatcher.Enqueue auf (Akzeptanzkriterium +// 1), exakt einmal je Eskalation. +package syncalert + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// NotificationDispatcher ist die schmale Schnittstelle zu Core CFG-02 +// (internal/notify.Dispatcher.Enqueue) — Mail ruft ausschließlich diese +// EINE Methode auf, kein eigener Versandcode. +type NotificationDispatcher interface { + Enqueue(ctx context.Context, channel, recipient string, payload map[string]any) (id string, err error) +} + +// HTTPNotificationDispatcher spricht CFG-02 über HTTP an — dieselbe +// Service-Credential-Konvention wie mail/internal/crypto.HTTPKEKProvider +// (API-02, X-Nexarch-Client-Id/Secret). +type HTTPNotificationDispatcher struct { + endpointURL string + clientID string + clientSecret string + httpClient *http.Client +} + +func NewHTTPNotificationDispatcher(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPNotificationDispatcher { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &HTTPNotificationDispatcher{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient} +} + +type enqueueRequest struct { + Channel string `json:"channel"` + Recipient string `json:"recipient"` + Payload map[string]any `json:"payload"` +} + +type enqueueResponse struct { + ID string `json:"id"` +} + +func (d *HTTPNotificationDispatcher) Enqueue(ctx context.Context, channel, recipient string, payload map[string]any) (string, error) { + body, err := json.Marshal(enqueueRequest{Channel: channel, Recipient: recipient, Payload: payload}) + if err != nil { + return "", fmt.Errorf("syncalert: anfrage serialisieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.endpointURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("syncalert: anfrage aufbauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Nexarch-Client-Id", d.clientID) + req.Header.Set("X-Nexarch-Client-Secret", d.clientSecret) + + resp, err := d.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("syncalert: anfrage senden: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("syncalert: cfg-02 lehnte anfrage ab: status %d", resp.StatusCode) + } + + var out enqueueResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("syncalert: antwort dekodieren: %w", err) + } + return out.ID, nil +} diff --git a/mail/internal/syncalert/dispatcher_http_test.go b/mail/internal/syncalert/dispatcher_http_test.go new file mode 100644 index 0000000..5fa83c5 --- /dev/null +++ b/mail/internal/syncalert/dispatcher_http_test.go @@ -0,0 +1,67 @@ +package syncalert + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestHTTPNotificationDispatcher_SendsCorrectRequestFormat beweist real +// über echtes HTTP, dass HTTPNotificationDispatcher Channel/Recipient/ +// Payload sowie die Service-Credential-Header korrekt sendet — kein +// eigener E-Mail-Versand, nur ein einziger CFG-02-Aufruf +// (Akzeptanzkriterium 1). +func TestHTTPNotificationDispatcher_SendsCorrectRequestFormat(t *testing.T) { + var capturedBody map[string]any + var capturedClientID, capturedClientSecret string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedClientID = r.Header.Get("X-Nexarch-Client-Id") + capturedClientSecret = r.Header.Get("X-Nexarch-Client-Secret") + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"real-notification-id-123"}`)) + })) + defer srv.Close() + + dispatcher := NewHTTPNotificationDispatcher(srv.URL, "mail", "mail-service-secret", nil) + id, err := dispatcher.Enqueue(context.Background(), NotificationChannel, AdminRecipient, map[string]any{ + "mailbox": "INBOX", + "reason": "verbindung abgelehnt", + }) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + if id != "real-notification-id-123" { + t.Fatalf("erwartete reale id vom server, habe %q", id) + } + if capturedClientID != "mail" || capturedClientSecret != "mail-service-secret" { + t.Fatalf("service-credential-header fehlen/falsch: id=%q secret=%q", capturedClientID, capturedClientSecret) + } + if capturedBody["channel"] != NotificationChannel || capturedBody["recipient"] != AdminRecipient { + t.Fatalf("channel/recipient falsch übertragen: %+v", capturedBody) + } + payload, ok := capturedBody["payload"].(map[string]any) + if !ok || payload["mailbox"] != "INBOX" { + t.Fatalf("payload nicht korrekt übertragen: %+v", capturedBody) + } +} + +// TestHTTPNotificationDispatcher_RejectedByServerReturnsError bestätigt, +// dass eine Ablehnung durch CFG-02 real als Fehler durchgereicht wird, +// statt stillschweigend zu verschwinden. +func TestHTTPNotificationDispatcher_RejectedByServerReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + dispatcher := NewHTTPNotificationDispatcher(srv.URL, "mail", "falsch", nil) + if _, err := dispatcher.Enqueue(context.Background(), "c", "r", nil); err == nil { + t.Fatal("erwartete fehler bei abgelehnter anfrage, habe nil") + } +} diff --git a/mail/internal/syncalert/migrations/0001_mail_sync_alert_state.sql b/mail/internal/syncalert/migrations/0001_mail_sync_alert_state.sql new file mode 100644 index 0000000..94833a8 --- /dev/null +++ b/mail/internal/syncalert/migrations/0001_mail_sync_alert_state.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS mail_sync_alert_state ( + tenant_slug TEXT NOT NULL, + mailbox_name TEXT NOT NULL, + consecutive_failures INT NOT NULL DEFAULT 0, + alerted BOOLEAN NOT NULL DEFAULT false, + last_success_at TIMESTAMPTZ, + last_failure_reason TEXT, + last_failure_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, mailbox_name) +) diff --git a/mail/internal/syncalert/monitor.go b/mail/internal/syncalert/monitor.go new file mode 100644 index 0000000..eb42ed5 --- /dev/null +++ b/mail/internal/syncalert/monitor.go @@ -0,0 +1,156 @@ +package syncalert + +import ( + "context" + _ "embed" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/0001_mail_sync_alert_state.sql +var schemaMigration string + +const ( + // NotificationChannel/AdminRecipient sind bewusst statisch (kleinste + // Lösung) — eine konfigurierbare Empfängerverwaltung ist Sache einer + // späteren Kachel, nicht Bestandteil von IMP-08. + NotificationChannel = "mail-sync-failure" + AdminRecipient = "mail-admins" +) + +// Monitor verfolgt Sync-Fehlschläge je Mandant/Postfach und löst bei +// Überschreiten der Schwelle GENAU EINE Benachrichtigung aus +// (Akzeptanzkriterium 1). +type Monitor struct { + pool *pgxpool.Pool + dispatcher NotificationDispatcher + threshold int + now func() time.Time +} + +// DefaultThreshold ist die Vorgabe-Eskalationsschwelle (konsekutive +// Fehlschläge), überschreibbar über WithThreshold. +const DefaultThreshold = 3 + +func NewMonitor(pool *pgxpool.Pool, dispatcher NotificationDispatcher) *Monitor { + return &Monitor{pool: pool, dispatcher: dispatcher, threshold: DefaultThreshold, now: time.Now} +} + +// WithThreshold setzt eine abweichende Eskalationsschwelle. +func (m *Monitor) WithThreshold(threshold int) *Monitor { + m.threshold = threshold + return m +} + +// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert. +func (m *Monitor) EnsureSchema(ctx context.Context) error { + if _, err := m.pool.Exec(ctx, schemaMigration); err != nil { + return fmt.Errorf("syncalert: schema anlegen: %w", err) + } + return nil +} + +type alertState struct { + consecutiveFailures int + alerted bool + lastSuccessAt *time.Time +} + +func (m *Monitor) getOrCreate(ctx context.Context, tenantSlug, mailboxName string) (alertState, error) { + if _, err := m.pool.Exec(ctx, ` + INSERT INTO mail_sync_alert_state (tenant_slug, mailbox_name) + VALUES ($1, $2) + ON CONFLICT (tenant_slug, mailbox_name) DO NOTHING + `, tenantSlug, mailboxName); err != nil { + return alertState{}, fmt.Errorf("syncalert: zustand anlegen: %w", err) + } + + var st alertState + err := m.pool.QueryRow(ctx, ` + SELECT consecutive_failures, alerted, last_success_at + FROM mail_sync_alert_state WHERE tenant_slug = $1 AND mailbox_name = $2 + `, tenantSlug, mailboxName).Scan(&st.consecutiveFailures, &st.alerted, &st.lastSuccessAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return alertState{}, fmt.Errorf("syncalert: gerade angelegten zustand nicht gefunden") + } + return alertState{}, fmt.Errorf("syncalert: zustand lesen: %w", err) + } + return st, nil +} + +// RecordFailure verzeichnet einen fehlgeschlagenen Sync-Versuch. Erst +// wenn consecutive_failures die konfigurierte Schwelle ERSTMALIG +// erreicht (noch nicht "alerted"), wird GENAU EINE Benachrichtigung an +// CFG-02 ausgelöst (Akzeptanzkriterium 1) — weitere Fehlschläge danach +// lösen KEINE zusätzliche Benachrichtigung aus, solange der Alarmzustand +// nicht durch einen erfolgreichen Sync zurückgesetzt wurde (kein +// Einzel-Alarm pro Fehlversuch, keine Spam-Flut). +func (m *Monitor) RecordFailure(ctx context.Context, tenantSlug, mailboxName, reason string) error { + now := m.now() + st, err := m.getOrCreate(ctx, tenantSlug, mailboxName) + if err != nil { + return err + } + + newFailures := st.consecutiveFailures + 1 + if _, err := m.pool.Exec(ctx, ` + UPDATE mail_sync_alert_state + SET consecutive_failures = $3, last_failure_reason = $4, last_failure_at = $5, updated_at = now() + WHERE tenant_slug = $1 AND mailbox_name = $2 + `, tenantSlug, mailboxName, newFailures, reason, now); err != nil { + return fmt.Errorf("syncalert: fehlschlag erfassen: %w", err) + } + + if newFailures < m.threshold || st.alerted { + return nil + } + + // Akzeptanzkriterium 2: Benachrichtigung enthält Postfach, + // Fehlerursache und Zeitpunkt des letzten erfolgreichen Abrufs. + payload := map[string]any{ + "tenant_slug": tenantSlug, + "mailbox": mailboxName, + "reason": reason, + "consecutive_failures": newFailures, + "last_successful_sync": formatOptionalTime(st.lastSuccessAt), + } + if _, err := m.dispatcher.Enqueue(ctx, NotificationChannel, AdminRecipient, payload); err != nil { + return fmt.Errorf("syncalert: benachrichtigung auslösen: %w", err) + } + + if _, err := m.pool.Exec(ctx, ` + UPDATE mail_sync_alert_state SET alerted = true, updated_at = now() + WHERE tenant_slug = $1 AND mailbox_name = $2 + `, tenantSlug, mailboxName); err != nil { + return fmt.Errorf("syncalert: alarmzustand markieren: %w", err) + } + return nil +} + +// RecordSuccess verzeichnet einen erfolgreichen Sync und setzt den +// Alarmzustand zurück (Akzeptanzkriterium 3) — der nächste Fehlschlag +// nach einem Erfolg beginnt wieder bei 0 konsekutiven Fehlschlägen. +func (m *Monitor) RecordSuccess(ctx context.Context, tenantSlug, mailboxName string) error { + now := m.now() + if _, err := m.pool.Exec(ctx, ` + INSERT INTO mail_sync_alert_state (tenant_slug, mailbox_name, consecutive_failures, alerted, last_success_at) + VALUES ($1, $2, 0, false, $3) + ON CONFLICT (tenant_slug, mailbox_name) DO UPDATE + SET consecutive_failures = 0, alerted = false, last_success_at = $3, updated_at = now() + `, tenantSlug, mailboxName, now); err != nil { + return fmt.Errorf("syncalert: erfolg erfassen: %w", err) + } + return nil +} + +func formatOptionalTime(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/mail/internal/syncalert/monitor_test.go b/mail/internal/syncalert/monitor_test.go new file mode 100644 index 0000000..028bbf7 --- /dev/null +++ b/mail/internal/syncalert/monitor_test.go @@ -0,0 +1,220 @@ +// 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") + } +}