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:
@@ -0,0 +1,52 @@
|
||||
// Package importnotify verbindet mail/internal/imapimport (ING-01/IMP-01)
|
||||
// mit mail/internal/notifyclient (INT-05, Core CFG-02/CFG-05):
|
||||
// genau EINE Benachrichtigung je abgeschlossenem Abgleichslauf
|
||||
// (imapimport.SyncResult), nicht eine je neuer Nachricht
|
||||
// (Akzeptanzkriterium 3: gebündelt statt Flut bei Massenimport).
|
||||
package importnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imapimport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/notifyclient"
|
||||
)
|
||||
|
||||
// eventTypeMailNew ist der bei Core registrierte Ereignistyp für neu
|
||||
// importierte Mails.
|
||||
const eventTypeMailNew = "mail.new"
|
||||
|
||||
// Notifier ist die für NotifyBatch benötigte Teilmenge von
|
||||
// *notifyclient.Client — als Schnittstelle für Tests ohne echten HTTP-
|
||||
// Server.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, ev notifyclient.Event) error
|
||||
}
|
||||
|
||||
// NotifyBatch löst — falls result.NewMessages > 0 — GENAU EINE
|
||||
// Benachrichtigung für den gesamten Abgleichslauf aus
|
||||
// (Akzeptanzkriterium 1: neue Mail löst zeitnah ein Ereignis aus;
|
||||
// Akzeptanzkriterium 3: Massenimport erzeugt eine gebündelte
|
||||
// Zusammenfassung statt Dutzende Einzelbenachrichtigungen — es gibt in
|
||||
// diesem Paket schlicht KEINEN Codepfad, der mehr als einen Notify-
|
||||
// Aufruf je Abgleichslauf absetzt). Bei result.NewMessages == 0 wird
|
||||
// nichts gesendet.
|
||||
//
|
||||
// Ein Fehler beim Senden wird zurückgeliefert, blockiert aber
|
||||
// strukturell NIE die bereits abgeschlossene Nachrichtenübernahme —
|
||||
// NotifyBatch wird vom Aufrufer NACH dem erfolgreichen
|
||||
// imapimport.RunOnce aufgerufen, nie währenddessen, und ein Fehler
|
||||
// hier nimmt keine bereits persistierte Nachricht zurück.
|
||||
func NotifyBatch(ctx context.Context, notifier Notifier, tenantSlug, mailboxName string, result imapimport.SyncResult) error {
|
||||
if result.NewMessages == 0 {
|
||||
return nil
|
||||
}
|
||||
summary := fmt.Sprintf("%d neue Mail(s) in %s", result.NewMessages, mailboxName)
|
||||
return notifier.Notify(ctx, notifyclient.Event{
|
||||
TenantSlug: tenantSlug,
|
||||
EventType: eventTypeMailNew,
|
||||
Summary: summary,
|
||||
Count: result.NewMessages,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package importnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imapimport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/notifyclient"
|
||||
)
|
||||
|
||||
type recordingNotifier struct {
|
||||
mu sync.Mutex
|
||||
events []notifyclient.Event
|
||||
}
|
||||
|
||||
func (r *recordingNotifier) Notify(_ context.Context, ev notifyclient.Event) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events = append(r.events, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingNotifier) count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.events)
|
||||
}
|
||||
|
||||
// TestNotifyBatch_SingleNewMessageTriggersExactlyOneNotification ist
|
||||
// die geforderte Pflichtprüfung 1 (INT-05): Import einer Mail löst
|
||||
// genau eine Benachrichtigung aus.
|
||||
func TestNotifyBatch_SingleNewMessageTriggersExactlyOneNotification(t *testing.T) {
|
||||
n := &recordingNotifier{}
|
||||
err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyBatch: %v", err)
|
||||
}
|
||||
if n.count() != 1 {
|
||||
t.Fatalf("erwartete genau 1 benachrichtigung, habe %d", n.count())
|
||||
}
|
||||
if n.events[0].Count != 1 || n.events[0].TenantSlug != "mandant-a" {
|
||||
t.Fatalf("unerwartetes ereignis: %+v", n.events[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyBatch_MassImportProducesOneBundledNotification ist die
|
||||
// geforderte Pflichtprüfung 2 (INT-05): Massenimport erzeugt eine
|
||||
// gebündelte Zusammenfassung statt Dutzende Einzelbenachrichtigungen.
|
||||
func TestNotifyBatch_MassImportProducesOneBundledNotification(t *testing.T) {
|
||||
n := &recordingNotifier{}
|
||||
err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyBatch: %v", err)
|
||||
}
|
||||
if n.count() != 1 {
|
||||
t.Fatalf("erwartete genau 1 GEBÜNDELTE benachrichtigung für 50 neue nachrichten, habe %d einzelne", n.count())
|
||||
}
|
||||
if n.events[0].Count != 50 {
|
||||
t.Fatalf("erwartete gebündelte anzahl 50, habe %d", n.events[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyBatch_NoNewMessagesSendsNothing stellt sicher, dass ein
|
||||
// Abgleichslauf ohne neue Nachrichten keine Benachrichtigung auslöst.
|
||||
func TestNotifyBatch_NoNewMessagesSendsNothing(t *testing.T) {
|
||||
n := &recordingNotifier{}
|
||||
if err := NotifyBatch(context.Background(), n, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 0}); err != nil {
|
||||
t.Fatalf("NotifyBatch: %v", err)
|
||||
}
|
||||
if n.count() != 0 {
|
||||
t.Fatalf("erwartete keine benachrichtigung ohne neue nachrichten, habe %d", n.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyBatch_DisabledNotificationDeliversNothing ist die
|
||||
// geforderte Pflichtprüfung 3 (INT-05): deaktivierte Benachrichtigung
|
||||
// erzeugt keine Zustellung — real gegen einen echten HTTP-Server
|
||||
// geprüft, der den CFG-05-Vertrag nachbildet: 204 bedeutet "durch
|
||||
// Benutzerpräferenz unterdrückt". NotifyBatch ruft trotzdem exakt
|
||||
// einmal auf (die Unterdrückungsentscheidung liegt bei Core, nicht bei
|
||||
// Mail), der Aufruf selbst liefert keinen Fehler.
|
||||
func TestNotifyBatch_DisabledNotificationDeliversNothing(t *testing.T) {
|
||||
var callCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusNoContent) // "durch benutzerpräferenz unterdrückt"
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := notifyclient.NewClient(srv.URL, "test-service-token")
|
||||
err := NotifyBatch(context.Background(), client, "mandant-a", "INBOX", imapimport.SyncResult{NewMessages: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyBatch: %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("erwartete genau 1 aufruf an den (unterdrückenden) server, habe %d", callCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user