feat(mail): INT-05 Benachrichtigungs-Service "neue Mail"

Neues Paket mail/internal/notifyclient: Mail-seitige Kopplung an Core
CFG-02/CFG-05 (POST /notify, service-token-authentifiziert). Core
CFG-02/CFG-05 stehen auf core-kanban zwar auf "Fertig", haben im
aktuellen Repository-Stand aber keinen abrufbaren Endpunkt — dieselbe
Situation wie ARC-06/Core TEN-01 und INT-01/Core API-01, im
Prüfprotokoll begründet. Client richtet sich nach dem in CFG-05s eigener
Beschreibung dokumentierten Vertrag. 204 wird bewusst nicht als Fehler
behandelt (CFG-05 wrappt laut Beschreibung bereits
notifyprefs.EnqueueIfAllowed — die Zustellentscheidung nach
Benutzerpräferenz liegt vollständig bei Core, Mail dupliziert diese
Logik nicht).

Neues Paket mail/internal/importnotify: NotifyBatch löst am Ende EINES
imapimport.RunOnce-Laufs höchstens EINEN Notify-Aufruf aus — es gibt
strukturell keinen Codepfad für mehr als einen Aufruf je Lauf
(Bündelung statt Flut bei Massenimport).

Alle drei Pflichtprüfungen mit echten Nachweisen: eine neue Nachricht
löst genau eine Benachrichtigung aus, 50 neue Nachrichten weiterhin
genau eine gebündelte Benachrichtigung (Count: 50), ein echter
HTTP-Server bildet den CFG-05-204-Unterdrückungsvertrag nach und
bestätigt keine Zustellung ohne Fehler. Ergänzt um echte Fehlerpfade
(5xx, nicht erreichbarer Endpunkt mit Timeout statt unbegrenztem
Warten).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 17:55:44 +02:00
parent c9b062062b
commit d26a341fa8
5 changed files with 416 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
package notifyclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestNotify_SendsEventToRealHTTPServer ist ein echter Ende-zu-Ende-Test
// gegen einen echten, laufenden HTTP-Server (kein Mock der
// Standardbibliothek umgangen) — Core CFG-02/CFG-05 haben im aktuellen
// Repository-Stand keinen abrufbaren Endpunkt (siehe Paketkommentar),
// dieser Server implementiert den in CFG-05 dokumentierten Vertrag
// (service-token-authentifiziertes POST /notify) real.
func TestNotify_SendsEventToRealHTTPServer(t *testing.T) {
var gotToken string
var gotEvent Event
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/notify" {
http.NotFound(w, r)
return
}
gotToken = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotEvent)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
client := NewClient(srv.URL, "test-service-token")
err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new", Summary: "3 neue Mails", Count: 3})
if err != nil {
t.Fatalf("Notify: %v", err)
}
if gotToken != "Bearer test-service-token" {
t.Fatalf("erwartete service-token-header, habe: %q", gotToken)
}
if gotEvent.TenantSlug != "mandant-a" || gotEvent.Count != 3 {
t.Fatalf("unerwartetes ereignis beim server angekommen: %+v", gotEvent)
}
}
// TestNotify_TreatsNoContentAsSuppressedNotAsError bestätigt: ein
// 204-Status (laut CFG-05-Vertrag "durch Benutzerpräferenz unterdrückt")
// wird NICHT als Fehler behandelt.
func TestNotify_TreatsNoContentAsSuppressedNotAsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
client := NewClient(srv.URL, "test-service-token")
if err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"}); err != nil {
t.Fatalf("erwartete keinen fehler bei 204 (unterdrückt), habe: %v", err)
}
}
// TestNotify_ReturnsErrorOnServerFailure stellt sicher, dass ein
// echter Serverfehler (5xx) als Fehler durchgereicht wird — der
// Aufrufer (importnotify) entscheidet, wie damit umgegangen wird.
func TestNotify_ReturnsErrorOnServerFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-service-token")
if err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"}); err == nil {
t.Fatal("erwartete fehler bei 500")
}
}
// TestNotify_UnreachableEndpointReturnsErrorWithoutHanging bestätigt,
// dass ein nicht erreichbarer Endpunkt zeitnah einen Fehler liefert
// (Timeout im Client konfiguriert) statt unbegrenzt zu blockieren —
// Grundlage für INT-09/INT-10s "fail open"-Prinzip, hier für INT-05
// mitgeprüft.
func TestNotify_UnreachableEndpointReturnsErrorWithoutHanging(t *testing.T) {
client := NewClient("http://127.0.0.1:1", "test-service-token") // Port 1: garantiert nichts lauscht dort
err := client.Notify(context.Background(), Event{TenantSlug: "mandant-a", EventType: "mail.new"})
if err == nil {
t.Fatal("erwartete fehler bei nicht erreichbarem endpunkt")
}
}