internal/channels: konkrete Zustellkanaele fuer CFG-02s Dispatcher.
TemplateStore.Resolve loest Vorlagen pro Tenant auf und faellt auf
GlobalTemplateScope zurueck, wenn ein Tenant keine eigene gesetzt hat
(Akzeptanzkriterium 3). Render nutzt text/template mit
Option("missingkey=error") — ein fehlender Platzhalter bricht das Rendering
MIT FEHLER ab, statt eine unvollstaendige Nachricht zu erzeugen
(Akzeptanzkriterium 1).
EmailSender implementiert notify.Sender: rendert ZUERST die Vorlage, bevor
ueberhaupt eine SMTP-Verbindung aufgebaut wird — schlaegt das Rendering
fehl, wird nie ein Netzwerkzugriff versucht. Ein anschliessend fehl-
schlagender SMTP-Versand liefert einen Fehler, den CFG-02s bereits
getestete Wiederholungslogik verarbeitet (kein zweiter Retry-Mechanismus
hier). InAppSender persistiert In-App-Nachrichten ueber InAppStore
(Akzeptanzkriterium 2, ueber API abrufbar/als gelesen markierbar). Router
waehlt den Kanal anhand Notification.Channel — ein neuer Kanal wird per
Register() ergaenzt, ohne Dispatcher oder Router umzubauen.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Vorlagenrendering mit fehlenden Platzhaltern bricht kontrolliert ab —
TestRender_MissingPlaceholderAborts und
TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing (Fehler kommt von der
Vorlagenaufloesung, kein SMTP-Verbindungsversuch). PASS.
2. In-App-Benachrichtigung nach Markierung als gelesen korrekt gefuehrt —
TestInAppStore_MarkReadIsReflectedCorrectly. PASS.
3. E-Mail-Versand bei nicht erreichbarem SMTP-Server loest dokumentiertes
Retry-Verhalten ueber CFG-02 aus —
TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP: echter
EmailSender gegen unerreichbaren Host, ueber notify.Dispatcher
eingereiht, nach ausgeschoepften Wiederholungen status=failed mit
korrekter Versuchszahl. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
208 lines
6.8 KiB
Go
208 lines
6.8 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*TemplateStore, *InAppStore, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS notification_templates (
|
|
tenant_slug TEXT NOT NULL, key TEXT NOT NULL, subject TEXT NOT NULL, body TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, key)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS in_app_notifications (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_slug TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
title TEXT NOT NULL, body TEXT NOT NULL, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS notification_jobs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
|
|
attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5,
|
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
cleanup := func() { pool.Close() }
|
|
return NewTemplateStore(pool), NewInAppStore(pool), cleanup
|
|
}
|
|
|
|
func uniqueKey(prefix string) string {
|
|
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Vorlagenrendering mit fehlenden
|
|
// Platzhaltern bricht kontrolliert ab.
|
|
func TestRender_MissingPlaceholderAborts(t *testing.T) {
|
|
tmpl := Template{Subject: "Hallo {{.name}}", Body: "Dein Code: {{.code}}"}
|
|
|
|
_, _, err := Render(tmpl, map[string]any{"name": "Alice"}) // "code" fehlt
|
|
if err == nil {
|
|
t.Fatal("erwartet fehler bei fehlendem platzhalter 'code'")
|
|
}
|
|
}
|
|
|
|
func TestRender_SucceedsWithAllPlaceholders(t *testing.T) {
|
|
tmpl := Template{Subject: "Hallo {{.name}}", Body: "Dein Code: {{.code}}"}
|
|
|
|
subject, body, err := Render(tmpl, map[string]any{"name": "Alice", "code": "1234"})
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
if subject != "Hallo Alice" || body != "Dein Code: 1234" {
|
|
t.Fatalf("unerwartet: subject=%q body=%q", subject, body)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: Vorlagen pro Tenant anpassbar, Fallback auf global.
|
|
func TestTemplateStore_TenantOverrideFallsBackToGlobal(t *testing.T) {
|
|
templates, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
key := uniqueKey("welcome")
|
|
if err := templates.Set(ctx, GlobalTemplateScope, key, "Willkommen", "Standardtext"); err != nil {
|
|
t.Fatalf("set global: %v", err)
|
|
}
|
|
if err := templates.Set(ctx, "acme", key, "Willkommen bei ACME", "ACME-Text"); err != nil {
|
|
t.Fatalf("set tenant: %v", err)
|
|
}
|
|
|
|
got, err := templates.Resolve(ctx, "acme", key)
|
|
if err != nil {
|
|
t.Fatalf("resolve acme: %v", err)
|
|
}
|
|
if got.Subject != "Willkommen bei ACME" {
|
|
t.Fatalf("erwartet tenant-vorlage, habe %q", got.Subject)
|
|
}
|
|
|
|
got, err = templates.Resolve(ctx, "globex", key) // hat keine eigene vorlage
|
|
if err != nil {
|
|
t.Fatalf("resolve globex: %v", err)
|
|
}
|
|
if got.Subject != "Willkommen" {
|
|
t.Fatalf("erwartet global-fallback, habe %q", got.Subject)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: als gelesen markiert wird korrekt gefuehrt.
|
|
func TestInAppStore_MarkReadIsReflectedCorrectly(t *testing.T) {
|
|
_, inApp, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
userID := uniqueKey("user")
|
|
id, err := inApp.Create(ctx, "acme", userID, "Titel", "Text")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
list, err := inApp.ListForUser(ctx, "acme", userID)
|
|
if err != nil {
|
|
t.Fatalf("list (vor markierung): %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ReadAt != nil {
|
|
t.Fatalf("erwartet 1 ungelesene benachrichtigung, habe %+v", list)
|
|
}
|
|
|
|
if err := inApp.MarkRead(ctx, id); err != nil {
|
|
t.Fatalf("mark read: %v", err)
|
|
}
|
|
|
|
list, err = inApp.ListForUser(ctx, "acme", userID)
|
|
if err != nil {
|
|
t.Fatalf("list (nach markierung): %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ReadAt == nil {
|
|
t.Fatalf("erwartet als gelesen markiert, habe %+v", list)
|
|
}
|
|
}
|
|
|
|
// EmailSender bricht ab, BEVOR SMTP ueberhaupt kontaktiert wird, wenn keine
|
|
// Vorlage aufloesbar ist — Nachweis, dass der Abbruch vor dem Netzwerkzugriff
|
|
// erfolgt (Akzeptanzkriterium 1).
|
|
func TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing(t *testing.T) {
|
|
templates, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
sender := NewEmailSender(templates, "nicht-aufloesbarer-smtp-host.invalid", "25", "noreply@example.com")
|
|
|
|
err := sender.Send(ctx, notify.Notification{
|
|
Recipient: "user@example.com",
|
|
Payload: map[string]any{"template_key": uniqueKey("nie_konfiguriert"), "tenant_slug": "acme"},
|
|
})
|
|
if !errors.Is(err, ErrTemplateNotFound) {
|
|
t.Fatalf("erwartet ErrTemplateNotFound (kein smtp-versuch), habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: E-Mail-Versand bei nicht erreichbarem
|
|
// SMTP-Server loest das dokumentierte Retry-Verhalten ueber CFG-02 aus.
|
|
func TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP(t *testing.T) {
|
|
templates, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
pool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
key := uniqueKey("retry_test")
|
|
if err := templates.Set(ctx, GlobalTemplateScope, key, "Betreff", "Text ohne Platzhalter"); err != nil {
|
|
t.Fatalf("set template: %v", err)
|
|
}
|
|
|
|
sender := NewEmailSender(templates, "nicht-aufloesbarer-smtp-host.invalid", "25", "noreply@example.com")
|
|
dispatcher := notify.NewDispatcher(pool).WithRetryPolicy(2, time.Millisecond)
|
|
|
|
jobID, err := dispatcher.Enqueue(ctx, "email", "user@example.com", map[string]any{"template_key": key, "tenant_slug": "acme"})
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
time.Sleep(5 * time.Millisecond)
|
|
if _, _, err := dispatcher.ProcessDue(ctx, sender, 10); err != nil {
|
|
t.Fatalf("process due %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
var status string
|
|
var attempts int
|
|
if err := pool.QueryRow(ctx, `SELECT status, attempts FROM notification_jobs WHERE id = $1`, jobID).Scan(&status, &attempts); err != nil {
|
|
t.Fatalf("status lesen: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Fatalf("erwartet status failed nach ausgeschoepften wiederholungen bei unerreichbarem smtp, habe %q", status)
|
|
}
|
|
if attempts != 2 {
|
|
t.Fatalf("erwartet 2 versuche, habe %d", attempts)
|
|
}
|
|
}
|