package tenant import ( "context" "errors" "fmt" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() adminPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("admin pool: %v", err) } registryPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("registry 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 )`); err != nil { t.Fatalf("registry-schema: %v", err) } registry := NewRegistry(registryPool) dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) provisioner := NewProvisioner(adminPool, registry, dsnTemplate) lifecycle := NewLifecycle(registry, adminPool) cleanup := func() { registryPool.Close() adminPool.Close() } _ = provisioner return registry, lifecycle, adminPool, cleanup } func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) { t.Helper() dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1) provisioner := NewProvisioner(adminPool, registry, dsnTemplate) if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil { t.Fatalf("provision %s: %v", slug, err) } t.Cleanup(func() { ctx := context.Background() _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug))) _, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) }) } // Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge). func TestLifecycle_SuspendAndReactivate(t *testing.T) { registry, _, adminPool, cleanup := newLifecycleTestSetup(t) defer cleanup() provisionTestTenant(t, registry, adminPool, "lc_suspend") ctx := context.Background() suspended, err := registry.Suspend(ctx, "lc_suspend") if err != nil { t.Fatalf("suspend: %v", err) } if suspended.Status != StatusSuspended { t.Fatalf("status = %q, want suspended", suspended.Status) } reactivated, err := registry.Reactivate(ctx, "lc_suspend") if err != nil { t.Fatalf("reactivate: %v", err) } if reactivated.Status != StatusActive { t.Fatalf("status = %q, want active", reactivated.Status) } } // Pruefung 1: ungueltige Uebergaenge werden abgewiesen. func TestLifecycle_RejectsInvalidTransitions(t *testing.T) { registry, _, adminPool, cleanup := newLifecycleTestSetup(t) defer cleanup() provisionTestTenant(t, registry, adminPool, "lc_invalid") ctx := context.Background() // Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang. if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) } if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil { t.Fatalf("suspend: %v", err) } // Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig. if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) } // CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig. if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) } if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) { t.Fatalf("erwartet ErrTenantNotFound, habe %v", err) } } // Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der // Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus, // mit exakter Wiederherstellung des jeweiligen Vorzustands. func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) { registry, _, adminPool, cleanup := newLifecycleTestSetup(t) defer cleanup() provisionTestTenant(t, registry, adminPool, "lc_cancel_active") provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended") ctx := context.Background() // Fall 1: aus 'active' heraus vorgemerkt und widerrufen. scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour) if err != nil { t.Fatalf("schedule deletion: %v", err) } if scheduled.Status != StatusPendingDeletion { t.Fatalf("status = %q, want pending_deletion", scheduled.Status) } if scheduled.DeletionScheduledAt == nil { t.Fatal("erwartet gesetzte deletion_scheduled_at") } restored, err := registry.CancelDeletion(ctx, "lc_cancel_active") if err != nil { t.Fatalf("cancel deletion: %v", err) } if restored.Status != StatusActive { t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status) } // Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu // 'suspended' zurueckkehren, NICHT zu 'active'. if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil { t.Fatalf("suspend: %v", err) } if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil { t.Fatalf("schedule deletion: %v", err) } restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended") if err != nil { t.Fatalf("cancel deletion: %v", err) } if restoredSuspended.Status != StatusSuspended { t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status) } } // Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem // Zugriffsversuch einen klaren Fehler. func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) { registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t) defer cleanup() provisionTestTenant(t, registry, adminPool, "lc_checkactive") ctx := context.Background() if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil { t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err) } if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil { t.Fatalf("suspend: %v", err) } for i := 0; i < 3; i++ { if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) { t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err) } } if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) { t.Fatalf("erwartet ErrTenantNotFound, habe %v", err) } } // Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit // automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions, // das RunSweeper periodisch aufruft). func TestLifecycle_ProcessDueDeletions(t *testing.T) { registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t) defer cleanup() provisionTestTenant(t, registry, adminPool, "lc_due") provisionTestTenant(t, registry, adminPool, "lc_not_due") ctx := context.Background() // lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig. if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil { t.Fatalf("schedule deletion (due): %v", err) } // lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig. if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil { t.Fatalf("schedule deletion (not due): %v", err) } processed, err := lifecycle.ProcessDueDeletions(ctx) if err != nil { t.Fatalf("process due deletions: %v", err) } if processed != 1 { t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed) } due, err := registry.GetBySlug(ctx, "lc_due") if err != nil { t.Fatalf("get lc_due: %v", err) } if due.Status != StatusDeleted { t.Fatalf("lc_due status = %q, want deleted", due.Status) } notDue, err := registry.GetBySlug(ctx, "lc_not_due") if err != nil { t.Fatalf("get lc_not_due: %v", err) } if notDue.Status != StatusPendingDeletion { t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status) } // Datenbank von lc_due wurde tatsaechlich physisch entfernt. var exists bool if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, dbNameForSlug("lc_due")).Scan(&exists); err != nil { t.Fatalf("pg_database pruefen: %v", err) } if exists { t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde") } }