package store import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "sort" "strings" "sync" "testing" ) // ── PostgreSQL in den Tests ────────────────────────────────────────────────── // // Ist MUNIN_TEST_PG gesetzt, laufen testStore und testControl — und damit // fast alle Store-Tests — gegen PostgreSQL statt gegen SQLite. Ohne die // Variable wird nur SQLite getestet, und `go test ./...` braucht kein Docker. // // MUNIN_TEST_PG='postgres://munin:munin@localhost:5432/munin_test?sslmode=disable' \ // go test ./... // // Jeder Test bekommt ein eigenes, frisch angelegtes Schema und löscht es am // Ende wieder. Die Tests stören sich also nicht gegenseitig und hinterlassen // nichts. Trotzdem gehört MUNIN_TEST_PG auf eine eigene Datenbank // (munin_test, legt docker-compose.dev.yml an), nicht auf die, mit der man // Munin lokal ausprobiert. const pgTestVariable = "MUNIN_TEST_PG" // pgTestEnabled sagt, ob die Tests gegen PostgreSQL laufen sollen. func pgTestEnabled() bool { return os.Getenv(pgTestVariable) != "" } var ( pgTestOnce sync.Once pgTestBackend *PostgresBackend pgTestErr error ) // testPostgres liefert den gemeinsamen Speicher aller PostgreSQL-Tests dieses // Laufs, oder überspringt den Test, wenn MUNIN_TEST_PG nicht gesetzt ist. // // Ein gemeinsamer Speicher statt einem je Test: Er hält eine // Verwaltungsverbindung offen, und die braucht es nur einmal. func testPostgres(t *testing.T) *PostgresBackend { t.Helper() if !pgTestEnabled() { t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable) } pgTestOnce.Do(func() { pgTestBackend, pgTestErr = NewPostgres(context.Background(), os.Getenv(pgTestVariable)) }) if pgTestErr != nil { t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestErr) } return pgTestBackend } // testPostgresStore öffnet einen Workspace in einem frischen Schema und löscht // es nach dem Test. func testPostgresStore(t *testing.T) *Store { t.Helper() sp := testPostgres(t) id := NewID() s, err := sp.OpenWorkspace(id) if err != nil { t.Fatalf("Workspace-Schema konnte nicht angelegt werden: %v", err) } t.Cleanup(func() { s.Close() if err := sp.DeleteWorkspace(id); err != nil { t.Errorf("Aufräumen: %v", err) } }) return s } // testPostgresControl öffnet eine Kontroll-Datenbank in einem eigenen Schema // (statt munin_control) und räumt danach auf — auch die Schemas der // Workspaces, die der Test darüber angelegt hat. func testPostgresControl(t *testing.T) *ControlStore { t.Helper() sp := testPostgres(t) // Eigener Speicher mit eigenem Kontroll-Schema, aber derselben // Verwaltungsverbindung. schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_") own := *sp own.controlSchema = schema ctx := context.Background() if err := own.createSchema(ctx, schema); err != nil { t.Fatal(err) } c, err := own.OpenControl() if err != nil { t.Fatalf("Kontroll-Schema konnte nicht angelegt werden: %v", err) } // Jeden angelegten Workspace mitschreiben. Die Liste aus der // Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen // Workspace löscht, ließe sonst dessen Schema liegen. tracker := &createdWorkspaces{PostgresBackend: &own} c.hooks = tracker t.Cleanup(func() { for _, id := range tracker.ids { if err := sp.DeleteWorkspace(id); err != nil { t.Errorf("Aufräumen: %v", err) } } c.Close() if _, err := sp.admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil { t.Errorf("Aufräumen: %v", err) } }) return c } // createdWorkspaces reicht die Haken an den Speicher weiter und merkt sich // dabei jeden Workspace, für den ein Schema angelegt wurde. type createdWorkspaces struct { *PostgresBackend mu sync.Mutex ids []string } func (a *createdWorkspaces) afterRename(ctx context.Context, workspaceID, name string) error { a.mu.Lock() if !slices.Contains(a.ids, workspaceID) { a.ids = append(a.ids, workspaceID) } a.mu.Unlock() return a.PostgresBackend.afterRename(ctx, workspaceID, name) } // ── Tests, die nur mit PostgreSQL laufen ───────────────────────────────────── // TestSchemaMatches prüft, dass die Migrationen beider Ordner dasselbe Schema // ergeben: dieselben Tabellen, Spalten, NOT NULL, Standardwerte und Indizes. // // TestMigrationDirsMatch stellt nur sicher, dass beide Ordner dieselben // Dateien haben. Erst dieser Test merkt, wenn eine Spalte auf einer Seite // anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10. func TestSchemaMatches(t *testing.T) { ctx := context.Background() t.Run("Workspace", func(t *testing.T) { sqlite, err := Open(filepath.Join(t.TempDir(), "vergleich.db")) if err != nil { t.Fatal(err) } defer sqlite.Close() pg := testPostgresStore(t) compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read)) }) t.Run("Kontrolle", func(t *testing.T) { sqlite, err := OpenControl(filepath.Join(t.TempDir(), "vergleich.db")) if err != nil { t.Fatal(err) } defer sqlite.Close() pg := testPostgresControl(t) compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read)) }) } // TestPostgresSchemaPerWorkspace prüft den Weg eines Workspace durch // PostgreSQL: Anlegen erzeugt ein Schema mit dem Namen als Kommentar, // Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema. func TestPostgresSchemaPerWorkspace(t *testing.T) { sp := testPostgres(t) c := testPostgresControl(t) ctx := context.Background() comment := func(schema string) (string, bool) { var text sql.NullString err := sp.admin.QueryRowContext(ctx, `SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1`, schema, ).Scan(&text) if errors.Is(err, sql.ErrNoRows) { return "", false } if err != nil { t.Fatal(err) } return text.String, true } w, err := c.CreateWorkspace(ctx, "Vertrieb & Co's") if err != nil { t.Fatal(err) } schema, _ := WorkspaceSchema(w.ID) if location := sp.Location(w.ID); location != schema { t.Errorf("Ort = %q, erwartet das Schema %q", location, schema) } if text, present := comment(schema); !present || text != "Vertrieb & Co's" { t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", present, text) } if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil { t.Fatal(err) } if text, _ := comment(schema); text != "Vertrieb Nord" { t.Errorf("nach dem Umbenennen: Kommentar=%q", text) } // Der Workspace lässt sich öffnen und benutzen. s, err := sp.OpenWorkspace(w.ID) if err != nil { t.Fatal(err) } if _, err := s.CreateProject(ctx, w.ID, "p", "P", ""); err != nil { t.Fatal(err) } s.Close() if err := sp.DeleteWorkspace(w.ID); err != nil { t.Fatal(err) } if _, present := comment(schema); present { t.Error("Schema ist nach DeleteWorkspace noch da") } } // TestPostgresWorkspacesSeparated: Was in einem Workspace-Schema steht, ist im // anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien. func TestPostgresWorkspacesSeparated(t *testing.T) { a := testPostgresStore(t) b := testPostgresStore(t) ctx := context.Background() if _, err := a.CreateProject(ctx, "ws-a", "nur-in-a", "Nur in A", ""); err != nil { t.Fatal(err) } if _, err := b.ProjectBySlug(ctx, "ws-a", "nur-in-a"); !errors.Is(err, ErrNotFound) { t.Errorf("Projekt aus Workspace A über Workspace B: err=%v, erwartet ErrNotFound", err) } } // TestPostgresCapacity: Zu viele Workspaces für max_connections werden mit // einer Meldung abgelehnt, die die nötige Zahl nennt. func TestPostgresCapacity(t *testing.T) { sp := testPostgres(t) ctx := context.Background() if err := sp.CheckCapacity(ctx, 1); err != nil { t.Errorf("ein Workspace sollte immer passen: %v", err) } err := sp.CheckCapacity(ctx, 100000) if err == nil || !strings.Contains(err.Error(), "max_connections auf mindestens") { t.Errorf("Fehler = %v, erwartet eine Meldung mit der nötigen Zahl", err) } } // ── Schema auslesen und vergleichen ────────────────────────────────────────── // schemaSnapshot ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass // SQLite und PostgreSQL vergleichbar werden. Jede Spalte wird zu einem Text // wie "text NOT NULL DEFAULT 0". type schemaSnapshot struct { columns map[string]string // "tabelle.spalte" → Beschreibung indexes []string // eigene Indizes (idx_…), sortiert } // typeClass fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite) // und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl. func typeClass(kind string) string { switch strings.ToLower(kind) { case "integer", "bigint", "int", "int8", "int4": return "ganzzahl" case "text": return "text" } return "?" + kind } // defaultValue gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte // den Typ an (leerer Text samt "::text"), SQLite nicht. var textTypeSpec = regexp.MustCompile(`::[a-z ]+$`) func defaultValue(value sql.NullString) string { if !value.Valid { return "" } return " DEFAULT " + textTypeSpec.ReplaceAllString(value.String, "") } func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaSnapshot { t.Helper() snapshot := schemaSnapshot{columns: map[string]string{}} tables := queryList(ctx, t, p, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) for _, table := range tables { rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, table) if err != nil { t.Fatal(err) } for rows.Next() { var name, kind string var notNull, pk int var standard sql.NullString if err := rows.Scan(&name, &kind, ¬Null, &standard, &pk); err != nil { t.Fatal(err) } // Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite // steht es nur dabei, wenn es ausdrücklich geschrieben wurde. snapshot.columns[table+"."+name] = columnsText(kind, notNull == 1 || pk > 0, standard) } rows.Close() } // Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen // für PRIMARY KEY und UNIQUE nicht. snapshot.indexes = queryList(ctx, t, p, `SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`) return snapshot } func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaSnapshot { t.Helper() snapshot := schemaSnapshot{columns: map[string]string{}} rows, err := p.QueryContext(ctx, `SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema()`) if err != nil { t.Fatal(err) } for rows.Next() { var table, name, kind, nullable string var standard sql.NullString if err := rows.Scan(&table, &name, &kind, &nullable, &standard); err != nil { t.Fatal(err) } snapshot.columns[table+"."+name] = columnsText(kind, nullable == "NO", standard) } rows.Close() // Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst // (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen // — so heißen sie in den Migrationen. snapshot.indexes = queryList(ctx, t, p, `SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE 'idx\_%' ORDER BY indexname`) return snapshot } func columnsText(kind string, notNull bool, standard sql.NullString) string { text := typeClass(kind) if notNull { text += " NOT NULL" } return text + defaultValue(standard) } func queryList(ctx context.Context, t *testing.T, p pool, query string) []string { t.Helper() rows, err := p.QueryContext(ctx, query) if err != nil { t.Fatal(err) } defer rows.Close() var list []string for rows.Next() { var s string if err := rows.Scan(&s); err != nil { t.Fatal(err) } list = append(list, s) } return list } func compareSchema(t *testing.T, sqlite, postgres schemaSnapshot) { t.Helper() all := map[string]bool{} for k := range sqlite.columns { all[k] = true } for k := range postgres.columns { all[k] = true } var names []string for k := range all { names = append(names, k) } sort.Strings(names) for _, name := range names { s, inSQLite := sqlite.columns[name] p, inPostgres := postgres.columns[name] switch { case !inSQLite: t.Errorf("%s gibt es nur in PostgreSQL (%s)", name, p) case !inPostgres: t.Errorf("%s gibt es nur in SQLite (%s)", name, s) case s != p: t.Errorf("%s unterscheidet sich:\n sqlite: %s\n postgres: %s", name, s, p) } } if fmt.Sprint(sqlite.indexes) != fmt.Sprint(postgres.indexes) { t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indexes, postgres.indexes) } }