package flag import ( "context" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupFlagStoreTest(t *testing.T) (*Store, 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 feature_flags ( key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false, rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`); err != nil { t.Fatalf("schema: %v", err) } cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`) pool.Close() } return NewStore(pool), cleanup } // Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test // die erwartete Auswertung. func TestService_TargetTenantStrategy(t *testing.T) { store, cleanup := setupFlagStoreTest(t) defer cleanup() ctx := context.Background() if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil { t.Fatalf("set: %v", err) } svc := NewService(store, time.Hour) if !svc.IsEnabled(ctx, "acme", "test_target_flag") { t.Fatal("erwartet true fuer tenant in zielgruppe") } if svc.IsEnabled(ctx, "globex", "test_target_flag") { t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe") } } // Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der // dokumentierten Cache-Invalidierungszeit, automatisiert gemessen. func TestService_CacheInvalidationTiming(t *testing.T) { store, cleanup := setupFlagStoreTest(t) defer cleanup() ctx := context.Background() const ttl = 150 * time.Millisecond if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil { t.Fatalf("set: %v", err) } svc := NewService(store, ttl) if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { t.Fatal("erwartet false vor der aenderung") } // Aenderung "auf einer anderen instanz" simulieren: direkt ueber den // Store, ohne svc.Invalidate aufzurufen. changedAt := time.Now() if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil { t.Fatalf("set: %v", err) } // Sofort danach sollte der Cache noch den alten Stand liefern. if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung") } deadline := changedAt.Add(ttl + 100*time.Millisecond) for time.Now().Before(deadline) { if svc.IsEnabled(ctx, "acme", "test_ttl_flag") { elapsed := time.Since(changedAt) t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl) return } time.Sleep(10 * time.Millisecond) } t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt)) } func TestService_InvalidateForcesImmediateRefresh(t *testing.T) { store, cleanup := setupFlagStoreTest(t) defer cleanup() ctx := context.Background() if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil { t.Fatalf("set: %v", err) } svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht _ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag") if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil { t.Fatalf("set: %v", err) } svc.Invalidate("test_invalidate_flag") if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") { t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate") } } // Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu // dokumentiertem Fallback-Verhalten, nicht zum Absturz. func TestService_FallsBackOnStoreFailure(t *testing.T) { store, cleanup := setupFlagStoreTest(t) defer cleanup() ctx := context.Background() if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil { t.Fatalf("set: %v", err) } svc := NewService(store, time.Hour) // Cache vorwaermen, waehrend die DB noch erreichbar ist. if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") { t.Fatal("erwartet true bei funktionierender db") } brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1") if err != nil { t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err) } brokenStore := NewStore(brokenPool) svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch svcWithCache.mu.Lock() svcWithCache.cache["test_fallback_flag"] = cacheEntry{ flag: Flag{Key: "test_fallback_flag", Enabled: true}, expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen } svcWithCache.mu.Unlock() func() { defer func() { if r := recover(); r != nil { t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r) } }() if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") { t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall") } }() // Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer // default false, kein absturz. freshSvc := NewService(brokenStore, time.Hour) func() { defer func() { if r := recover(); r != nil { t.Fatalf("IsEnabled hat gepanict: %v", r) } }() if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") { t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db") } }() }