package migrate import ( "context" "fmt" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) func TestLoadMigrations_SortsByFilename(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "0002_second.up.sql", "SELECT 2;") writeFile(t, dir, "0001_first.up.sql", "SELECT 1;") writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden migrations, err := LoadMigrations(dir) if err != nil { t.Fatalf("load: %v", err) } if len(migrations) != 2 { t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations)) } if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" { t.Fatalf("unerwartete reihenfolge: %+v", migrations) } } func writeFile(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *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 := tenant.NewRegistry(registryPool) dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate) var tenants []tenant.Tenant var slugs []string for i := 0; i < tenantCount; i++ { slug := fmt.Sprintf("mig_t%d", i) slugs = append(slugs, slug) tn, err := provisioner.Provision(ctx, slug, slug) if err != nil { t.Fatalf("provision %s: %v", slug, err) } tenants = append(tenants, tn) } orchestrator := NewOrchestrator(registry) cleanup := func() { for _, slug := range slugs { _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug)) } _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs) registryPool.Close() adminPool.Close() } return orchestrator, tenants, adminPool, cleanup } // Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer // davon absichtlich inkompatibel — die anderen laufen trotzdem durch. func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) { orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3) defer cleanup() ctx := context.Background() migrations := []Migration{ {Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"}, {Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"}, } // tenants[1] absichtlich inkompatibel machen: demo_b existiert schon, // migration 0002 schlaegt dort mit "already exists" fehl. badTenant := tenants[1] badPool, err := pgxpool.New(ctx, badTenant.DBDSN) if err != nil { t.Fatalf("connect bad tenant: %v", err) } if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil { t.Fatalf("inkompatiblen zustand vorbereiten: %v", err) } badPool.Close() results, err := orchestrator.RolloutAll(ctx, migrations) if err != nil { t.Fatalf("rollout: %v", err) } if len(results) != 3 { t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results)) } byslug := map[string]TenantResult{} for _, r := range results { byslug[r.TenantSlug] = r } good0 := byslug[tenants[0].Slug] if good0.Err != nil || len(good0.Applied) != 2 { t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0) } good2 := byslug[tenants[2].Slug] if good2.Err != nil || len(good2.Applied) != 2 { t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2) } bad := byslug[badTenant.Slug] if bad.Err == nil { t.Fatal("erwartet fehler fuer den inkompatiblen tenant") } if bad.FailedAt != "0002_demo_b" { t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt) } if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" { t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied) } } // Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer // jeden Tenant den korrekten, unabhaengigen Stand. func TestStatus_ReflectsPerTenantState(t *testing.T) { orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1) defer cleanup() ctx := context.Background() migrations := []Migration{ {Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"}, {Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"}, } if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil { t.Fatalf("rollout: %v", err) } status, err := orchestrator.Status(ctx, tenants[0].Slug) if err != nil { t.Fatalf("status: %v", err) } if len(status) != 2 { t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status)) } if !status[0].Success || status[0].Version != "0001_ok" { t.Fatalf("status[0] unerwartet: %+v", status[0]) } if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" { t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1]) } } // Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche // Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen, // sobald die Ursache behoben ist. func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) { orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1) defer cleanup() ctx := context.Background() // 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon // existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen). pool, err := pgxpool.New(ctx, tenants[0].DBDSN) if err != nil { t.Fatalf("connect: %v", err) } if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil { t.Fatalf("vorbedingung: %v", err) } migrations := []Migration{ {Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS, // damit ein erneutes Anwenden nachweislich fehlschlagen wuerde. {Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"}, } firstRun, err := orchestrator.RolloutAll(ctx, migrations) if err != nil { t.Fatalf("rollout 1: %v", err) } if firstRun[0].FailedAt != "0002_conflict" { t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0]) } // Ursache beheben. if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil { t.Fatalf("ursache beheben: %v", err) } pool.Close() secondRun, err := orchestrator.RolloutAll(ctx, migrations) if err != nil { t.Fatalf("rollout 2: %v", err) } // Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne // IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen // Applied-Eintrags fuer 0002_conflict. if secondRun[0].Err != nil { t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0]) } if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" { t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied) } }