package tenant import ( "context" "errors" "fmt" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" ) func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, 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() )`); err != nil { t.Fatalf("registry-schema: %v", err) } registry := NewRegistry(registryPool) dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) provisioner := NewProvisioner(adminPool, registry, dsnTemplate) var tenants []Tenant var slugs []string for i := 0; i < tenantCount; i++ { slug := fmt.Sprintf("router_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) } router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen cleanup := func() { router.Close() for _, slug := range slugs { _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug))) } _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs) registryPool.Close() adminPool.Close() } return router, tenants, cleanup } // Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest. func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) { router, tenants, cleanup := newTestRouterSetup(t, 2) defer cleanup() ctx := context.Background() pool, err := router.Resolve(ctx, tenants[0].Slug) if err != nil { t.Fatalf("resolve: %v", err) } var dbName string if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil { t.Fatalf("current_database: %v", err) } if dbName != tenants[0].DBName { t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName) } } // Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext // wird explizit abgewiesen statt irgendeine Verbindung zu liefern. func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) { router, _, cleanup := newTestRouterSetup(t, 1) defer cleanup() ctx := context.Background() if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) { t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err) } if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) { t.Fatalf("erwartet ErrUnknownTenant, habe %v", err) } } // Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich // gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein // erneuter Verbindungsaufbau. func TestRouter_ReusesConnectionForSameTenant(t *testing.T) { router, tenants, cleanup := newTestRouterSetup(t, 1) defer cleanup() ctx := context.Background() first, err := router.Resolve(ctx, tenants[0].Slug) if err != nil { t.Fatalf("resolve 1: %v", err) } second, err := router.Resolve(ctx, tenants[0].Slug) if err != nil { t.Fatalf("resolve 2: %v", err) } if first != second { t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche") } } // Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten // als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt // (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl. func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) { const tenantCount = 6 router, tenants, cleanup := newTestRouterSetup(t, tenantCount) defer cleanup() ctx := context.Background() for _, tn := range tenants { if _, err := router.Resolve(ctx, tn.Slug); err != nil { t.Fatalf("resolve %s: %v", tn.Slug, err) } if router.OpenCount() > 2 { t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount()) } } if router.OpenCount() != 2 { t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount()) } // Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar // (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand). pool, err := router.Resolve(ctx, tenants[0].Slug) if err != nil { t.Fatalf("resolve nach eviction: %v", err) } var one int if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil { t.Fatalf("query nach re-resolve: %v", err) } }