munin/internal/store/postgres_test.go
2026-10-19 10:13:56 +02:00

418 lines
13 KiB
Go

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, &notNull, &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)
}
}