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) } }