TEN-03: tenant-einstellungen-branding

internal/tenantsettings: pro-Tenant-Einstellungen (Anzeigename, Logo,
Farbschema, Zeitzone, Sprache) in der Registry-DB, alle Spalten nullable —
fehlender Wert bedeutet immer "Systemvoreinstellung verwenden"
(Defaults(): color_scheme=system, timezone=UTC, language=de), niemals ein
Fehler (Akzeptanzkriterium 2). Store.Update schreibt aktuellen Stand +
Historieneintrag atomar in einer Transaktion mit FOR-UPDATE-Lock auf der
aktuellen Zeile (Akzeptanzkriterium 3, Race-sicher bei nebenlaeufigen
Updates desselben Tenants). Patch-Typ mit *string-Feldern erlaubt
Teil-Updates ohne unbeteiligte Felder zu beruehren.

Schlanker Handler (Get/Update ueber tenant_id-Query-Parameter) als
Vorbereitung der REST-Schnittstelle — echte Auth/Versionierung kommt erst
mit API-01/IAM-02/RBAC-01.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Aenderung eines Tenants wirkt sich nicht auf einen anderen aus —
   TestUpdate_IsolatedBetweenTenants: Tenant A geaendert, Tenant B bleibt
   nachweislich bei Defaults(). PASS.
2. Fehlende Werte liefern Defaults statt Fehler —
   TestGet_UnsetTenantReturnsDefaults (Tenant ganz ohne Datensatz) und
   TestUpdate_PartialPatchKeepsOtherFieldsAtDefault (nur ein Feld gesetzt,
   Rest bleibt Default). PASS.
3. API-Schema von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3):
TestUpdate_HistoryTracksVersions — 3 aufeinanderfolgende Aenderungen,
Historie liefert alle 3 in korrekter Reihenfolge, nicht angefasste Felder
bleiben aus dem vorherigen Update erhalten. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-08-27 19:34:09 +02:00
co-authored by Claude Sonnet 5
parent 5915a4e7b1
commit 383da92ca8
7 changed files with 495 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
package tenantsettings
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, string, string, 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 tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_settings_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL,
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
var tenantA, tenantB string
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
t.Fatalf("tenant a anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
t.Fatalf("tenant b anlegen: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
pool.Close()
}
return NewStore(pool), tenantA, tenantB, cleanup
}
func strp(s string) *string { return &s }
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Get(ctx, tenantA)
if err != nil {
t.Fatalf("get: %v", err)
}
want := Defaults()
if got != want {
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
}
}
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
if err != nil {
t.Fatalf("update: %v", err)
}
if got.DisplayName != "Acme GmbH" {
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
}
d := Defaults()
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
}
if got.Version != 1 {
t.Fatalf("erwartet version 1, habe %d", got.Version)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
// nicht auf einen anderen aus.
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
store, tenantA, tenantB, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
t.Fatalf("update a: %v", err)
}
gotB, err := store.Get(ctx, tenantB)
if err != nil {
t.Fatalf("get b: %v", err)
}
if gotB != Defaults() {
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
}
}
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
func TestUpdate_HistoryTracksVersions(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
t.Fatalf("update 1: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
t.Fatalf("update 2: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
t.Fatalf("update 3: %v", err)
}
history, err := store.History(ctx, tenantA)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 3 {
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
}
if history[0].DisplayName != "v1" || history[0].Version != 1 {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].DisplayName != "v2" || history[1].Version != 2 {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
}
}