internal/cfgservice: Store (Schreiben/Historie) + Service (Lesen mit Vorrangregel + TTL-Cache, Default 5s, analog internal/flag). Genannt "cfgservice" statt "config", da internal/config bereits die Bootstrap- Konfiguration des Core-Prozesses selbst belegt. Store.Set schreibt aktuellen Stand (config_values) und Historieneintrag (config_value_history) atomar in einer Transaktion — eine Aenderung ohne Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2). Version wird pro (key, scope) monoton hochgezaehlt. Service.Resolve wendet die Vorrangregel an: Tenant-spezifischer Override (scope = Tenant-Slug) hat Vorrang vor globalem Default (scope = 'global'), faellt sauber zurueck wenn kein Override existiert (Akzeptanzkriterium 1). Invalidate erzwingt sofortiges Neuladen fuer den Schreiber, andere Instanzen sehen Aenderungen spaetestens nach der TTL. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Vorrangregel automatisiert getestet — TestService_TenantOverrideTakesPrecedenceOverGlobal: Tenant mit Override bekommt Tenant-Wert, Tenant ohne Override bekommt Global-Default. PASS. 2. Cache-Invalidierung nach Aenderung innerhalb dokumentierter Zeit gemessen — TestService_CacheInvalidationTiming: wirksam nach 154ms bei TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS. 3. Versionierungshistorie ueber mehrere Aenderungen nachvollzogen — TestStore_HistoryTracksAllChanges: 3 aufeinanderfolgende Aenderungen, Historie liefert alle 3 in korrekter Reihenfolge mit korrekten Versionsnummern. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
3.0 KiB
Go
120 lines
3.0 KiB
Go
package cfgservice
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func setupStoreTest(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 config_values (
|
|
key TEXT NOT NULL,
|
|
scope TEXT NOT NULL CHECK (scope <> ''),
|
|
value TEXT NOT NULL,
|
|
version INT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (key, scope)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config_value_history (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
key TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
version INT NOT NULL,
|
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
|
pool.Close()
|
|
}
|
|
return NewStore(pool), cleanup
|
|
}
|
|
|
|
func TestStore_SetIncrementsVersion(t *testing.T) {
|
|
store, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert")
|
|
if err != nil {
|
|
t.Fatalf("set 1: %v", err)
|
|
}
|
|
if v1.Version != 1 {
|
|
t.Fatalf("erwartet version 1, habe %d", v1.Version)
|
|
}
|
|
|
|
v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert")
|
|
if err != nil {
|
|
t.Fatalf("set 2: %v", err)
|
|
}
|
|
if v2.Version != 2 {
|
|
t.Fatalf("erwartet version 2, habe %d", v2.Version)
|
|
}
|
|
|
|
got, err := store.Get(ctx, "test_key", GlobalScope)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.Value != "zweiter-wert" || got.Version != 2 {
|
|
t.Fatalf("aktueller wert unerwartet: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere
|
|
// Aenderungen hinweg nachvollzogen.
|
|
func TestStore_HistoryTracksAllChanges(t *testing.T) {
|
|
store, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
values := []string{"v1", "v2", "v3"}
|
|
for _, v := range values {
|
|
if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil {
|
|
t.Fatalf("set %q: %v", v, err)
|
|
}
|
|
}
|
|
|
|
history, err := store.History(ctx, "test_history_key", GlobalScope)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(history) != 3 {
|
|
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
|
}
|
|
for i, h := range history {
|
|
if h.Version != i+1 || h.Value != values[i] {
|
|
t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) {
|
|
store, cleanup := setupStoreTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("erwartet ErrNotFound, habe %v", err)
|
|
}
|
|
}
|