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>
157 lines
5.1 KiB
Go
157 lines
5.1 KiB
Go
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])
|
|
}
|
|
}
|