internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache, Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt, als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur. evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus, Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der TTL (Akzeptanzkriterium 3, kein Neustart noetig). Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice normalisiert. Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt strukturell der Archive-Retention-Engine vorbehalten. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Cache-Invalidierungszeit automatisiert gemessen — TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS. 2. Zielgruppen-Strategie liefert erwartete Auswertung — TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS. 3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz — TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS. 4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt konsistent nachvollziehbar) mitabgedeckt. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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")
|
|
}
|
|
}()
|
|
}
|