package notifyprefs import ( "context" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/notify" ) func setupTest(t *testing.T) (*Store, *notify.Dispatcher, *pgxpool.Pool, 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_preferences ( tenant_slug TEXT NOT NULL, user_id TEXT NOT NULL, event_type TEXT NOT NULL, channel TEXT NOT NULL, enabled BOOLEAN NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, user_id, event_type, channel) ); 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', 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.Exec(ctx, `DELETE FROM notification_preferences`) _, _ = pool.Exec(ctx, `DELETE FROM notification_jobs`) pool.Close() } return NewStore(pool), notify.NewDispatcher(pool), pool, cleanup } // Ohne explizite Praeferenz gilt ein Kanal als aktiviert (Opt-out-Default). func TestIsEnabled_DefaultsToTrueWithoutPreference(t *testing.T) { prefs, _, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() enabled, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "email") if err != nil { t.Fatalf("is enabled: %v", err) } if !enabled { t.Fatal("erwartet aktiviert als default ohne gesetzte praeferenz") } } // Akzeptanzkriterium 1 + 2: gesetzte Praeferenz wirkt sofort. func TestSetAndIsEnabled(t *testing.T) { prefs, _, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { t.Fatalf("set: %v", err) } enabled, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "email") if err != nil { t.Fatalf("is enabled: %v", err) } if enabled { t.Fatal("erwartet deaktiviert nach Set(..., false)") } // Andere Kanaele/Ereignistypen desselben Benutzers bleiben unberuehrt. stillOn, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "in_app") if err != nil { t.Fatalf("is enabled (anderer kanal): %v", err) } if !stillOn { t.Fatal("erwartet: anderer kanal bleibt aktiviert") } } // Akzeptanzkriterium 1 / Pruefung 2: Einstellungen eines Benutzers wirken // sich NICHT auf einen anderen Benutzer aus. func TestSet_DoesNotAffectOtherUser(t *testing.T) { prefs, _, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { t.Fatalf("set fuer user-1: %v", err) } enabledForOther, err := prefs.IsEnabled(ctx, "acme", "user-2", "welcome", "email") if err != nil { t.Fatalf("is enabled fuer user-2: %v", err) } if !enabledForOther { t.Fatal("praeferenz von user-1 haette user-2 nicht beeinflussen duerfen") } } // Akzeptanzkriterium 1 / Pruefung 1 (Kernbeweis): ein deaktivierter Kanal // erhaelt NACHWEISLICH keine weitere Zustellung — geprueft ueber die // tatsaechliche notification_jobs-Tabelle, nicht nur ueber den Rueckgabewert. func TestEnqueueIfAllowed_SkipsWhenDisabled(t *testing.T) { prefs, dispatcher, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if err := prefs.Set(ctx, "acme", "user-1", "invoice_ready", "email", false); err != nil { t.Fatalf("set: %v", err) } jobID, skipped, err := EnqueueIfAllowed(ctx, prefs, dispatcher, "acme", "user-1", "invoice_ready", "email", "user1@acme.example", nil) if err != nil { t.Fatalf("enqueue if allowed: %v", err) } if !skipped { t.Fatal("erwartet skipped=true bei deaktiviertem kanal") } if jobID != "" { t.Fatalf("erwartet keine job-id, habe %q", jobID) } var count int if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_jobs`).Scan(&count); err != nil { t.Fatalf("notification_jobs zaehlen: %v", err) } if count != 0 { t.Fatalf("erwartet 0 eingereihte jobs bei deaktiviertem kanal, habe %d", count) } } func TestEnqueueIfAllowed_EnqueuesWhenEnabled(t *testing.T) { prefs, dispatcher, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() jobID, skipped, err := EnqueueIfAllowed(ctx, prefs, dispatcher, "acme", "user-1", "invoice_ready", "email", "user1@acme.example", nil) if err != nil { t.Fatalf("enqueue if allowed: %v", err) } if skipped { t.Fatal("erwartet skipped=false ohne deaktivierte praeferenz") } if jobID == "" { t.Fatal("erwartet gesetzte job-id") } } // Akzeptanzkriterium 3: Tenant-Admin-Übersicht zeigt alle Benutzer. func TestListForTenant_ShowsAllUsers(t *testing.T) { prefs, _, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { t.Fatalf("set user-1: %v", err) } if err := prefs.Set(ctx, "acme", "user-2", "welcome", "in_app", false); err != nil { t.Fatalf("set user-2: %v", err) } if err := prefs.Set(ctx, "other-tenant", "user-3", "welcome", "email", false); err != nil { t.Fatalf("set user-3 (anderer tenant): %v", err) } overview, err := prefs.ListForTenant(ctx, "acme") if err != nil { t.Fatalf("list for tenant: %v", err) } if len(overview) != 2 { t.Fatalf("erwartet 2 eintraege fuer tenant acme (nicht den anderen tenant), habe %d", len(overview)) } }