180 lines
5.5 KiB
Go
180 lines
5.5 KiB
Go
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")
|
|
}
|
|
}()
|
|
}
|