package tenantadmin import ( "context" "errors" "fmt" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) type testEnv struct { handler *Handler superadminID string slug string adminPool *pgxpool.Pool registry *tenant.Registry } func setupTest(t *testing.T) (testEnv, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() registryPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("registry pool: %v", err) } adminPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("admin pool: %v", err) } if _, err := registryPool.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(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ ); 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() ); CREATE TABLE IF NOT EXISTS superadmins ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } registry := tenant.NewRegistry(registryPool) dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate) lifecycle := tenant.NewLifecycle(registry, adminPool) settingsStore := tenantsettings.NewStore(registryPool) superadmins := user.NewSuperadminStore(registryPool) slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000) if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil { t.Fatalf("provision: %v", err) } admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin") if err != nil { t.Fatalf("superadmin anlegen: %v", err) } handler := NewHandler(registry, lifecycle, settingsStore, superadmins) cleanup := func() { _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug)) _, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug) _, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug) _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) _, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID) registryPool.Close() adminPool.Close() } return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup } // Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins // sehen die Mandantenliste ueberhaupt. func TestListTenants_RejectsNonSuperadmin(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() _, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "") if !errors.Is(err, ErrNotSuperadmin) { t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err) } } func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() all, err := env.handler.ListTenants(ctx, env.superadminID, "", "") if err != nil { t.Fatalf("list: %v", err) } found := false for _, item := range all { if item.Slug == env.slug { found = true if item.Status != "active" { t.Fatalf("status = %q, want active", item.Status) } } } if !found { t.Fatal("erwartet test-tenant in ungefilterter liste") } bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "") if err != nil { t.Fatalf("list mit suche: %v", err) } if len(bySearch) != 1 || bySearch[0].Slug != env.slug { t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch) } byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended") if err != nil { t.Fatalf("list mit statusfilter: %v", err) } if len(byWrongStatus) != 0 { t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus) } } // Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename) // wird serverseitig abgelehnt, bevor etwas gespeichert wird. func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() _, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "}) if !errors.Is(err, ErrMissingDisplayName) { t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err) } } func TestUpdateSettings_AppliesValidPatch(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"}) if err != nil { t.Fatalf("update: %v", err) } if updated.DisplayName != "Neuer Name" { t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName) } detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug) if err != nil { t.Fatalf("detail: %v", err) } if detail.Settings.DisplayName != "Neuer Name" { t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings) } } // Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich // auf den tatsaechlichen Mandantenstatus aus. func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend) if err != nil { t.Fatalf("suspend: %v", err) } if suspended.Status != tenant.StatusSuspended { t.Fatalf("status = %q, want suspended", suspended.Status) } reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate) if err != nil { t.Fatalf("reactivate: %v", err) } if reactivated.Status != tenant.StatusActive { t.Fatalf("status = %q, want active", reactivated.Status) } } func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() // Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang. _, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate) if !errors.Is(err, tenant.ErrInvalidTransition) { t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err) } } func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) { env, cleanup := setupTest(t) defer cleanup() ctx := context.Background() _, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend) if !errors.Is(err, ErrNotSuperadmin) { t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err) } }