418 lines
13 KiB
Go
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, ¬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)
|
|
}
|
|
}
|