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"
|
|
|
|
// pgTestAktiv sagt, ob die Tests gegen PostgreSQL laufen sollen.
|
|
func pgTestAktiv() bool { return os.Getenv(pgTestVariable) != "" }
|
|
|
|
var (
|
|
pgTestOnce sync.Once
|
|
pgTestSpeicher *PostgresSpeicher
|
|
pgTestFehler 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) *PostgresSpeicher {
|
|
t.Helper()
|
|
if !pgTestAktiv() {
|
|
t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable)
|
|
}
|
|
pgTestOnce.Do(func() {
|
|
pgTestSpeicher, pgTestFehler = NewPostgres(context.Background(), os.Getenv(pgTestVariable))
|
|
})
|
|
if pgTestFehler != nil {
|
|
t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestFehler)
|
|
}
|
|
return pgTestSpeicher
|
|
}
|
|
|
|
// 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(), "-", "_")
|
|
eigener := *sp
|
|
eigener.controlSchema = schema
|
|
ctx := context.Background()
|
|
if err := eigener.legeSchemaAn(ctx, schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c, err := eigener.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.
|
|
merker := &angelegteWorkspaces{PostgresSpeicher: &eigener}
|
|
c.haken = merker
|
|
|
|
t.Cleanup(func() {
|
|
for _, id := range merker.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
|
|
}
|
|
|
|
// angelegteWorkspaces reicht die Haken an den Speicher weiter und merkt sich
|
|
// dabei jeden Workspace, für den ein Schema angelegt wurde.
|
|
type angelegteWorkspaces struct {
|
|
*PostgresSpeicher
|
|
mu sync.Mutex
|
|
ids []string
|
|
}
|
|
|
|
func (a *angelegteWorkspaces) nachDemBenennen(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.PostgresSpeicher.nachDemBenennen(ctx, workspaceID, name)
|
|
}
|
|
|
|
// ── Tests, die nur mit PostgreSQL laufen ─────────────────────────────────────
|
|
|
|
// TestSchemaGleich prüft, dass die Migrationen beider Ordner dasselbe Schema
|
|
// ergeben: dieselben Tabellen, Spalten, NOT NULL, Standardwerte und Indizes.
|
|
//
|
|
// TestMigrationsordnerGleich 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 TestSchemaGleich(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)
|
|
|
|
vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen))
|
|
})
|
|
|
|
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)
|
|
|
|
vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen))
|
|
})
|
|
}
|
|
|
|
// TestPostgresSchemaJeWorkspace 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 TestPostgresSchemaJeWorkspace(t *testing.T) {
|
|
sp := testPostgres(t)
|
|
c := testPostgresControl(t)
|
|
ctx := context.Background()
|
|
|
|
kommentar := 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 ort := sp.Ort(w.ID); ort != schema {
|
|
t.Errorf("Ort = %q, erwartet das Schema %q", ort, schema)
|
|
}
|
|
|
|
if text, da := kommentar(schema); !da || text != "Vertrieb & Co's" {
|
|
t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", da, text)
|
|
}
|
|
|
|
if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if text, _ := kommentar(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 _, da := kommentar(schema); da {
|
|
t.Error("Schema ist nach DeleteWorkspace noch da")
|
|
}
|
|
}
|
|
|
|
// TestPostgresWorkspacesGetrennt: Was in einem Workspace-Schema steht, ist im
|
|
// anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien.
|
|
func TestPostgresWorkspacesGetrennt(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)
|
|
}
|
|
}
|
|
|
|
// TestPostgresKapazitaet: Zu viele Workspaces für max_connections werden mit
|
|
// einer Meldung abgelehnt, die die nötige Zahl nennt.
|
|
func TestPostgresKapazitaet(t *testing.T) {
|
|
sp := testPostgres(t)
|
|
ctx := context.Background()
|
|
|
|
if err := sp.PruefeKapazitaet(ctx, 1); err != nil {
|
|
t.Errorf("ein Workspace sollte immer passen: %v", err)
|
|
}
|
|
err := sp.PruefeKapazitaet(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 ──────────────────────────────────────────
|
|
|
|
// schemaBild 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 schemaBild struct {
|
|
spalten map[string]string // "tabelle.spalte" → Beschreibung
|
|
indizes []string // eigene Indizes (idx_…), sortiert
|
|
}
|
|
|
|
// typKlasse fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite)
|
|
// und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl.
|
|
func typKlasse(typ string) string {
|
|
switch strings.ToLower(typ) {
|
|
case "integer", "bigint", "int", "int8", "int4":
|
|
return "ganzzahl"
|
|
case "text":
|
|
return "text"
|
|
}
|
|
return "?" + typ
|
|
}
|
|
|
|
// standardwert gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte
|
|
// den Typ an (leerer Text samt "::text"), SQLite nicht.
|
|
var textTypAngabe = regexp.MustCompile(`::[a-z ]+$`)
|
|
|
|
func standardwert(wert sql.NullString) string {
|
|
if !wert.Valid {
|
|
return ""
|
|
}
|
|
return " DEFAULT " + textTypAngabe.ReplaceAllString(wert.String, "")
|
|
}
|
|
|
|
func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaBild {
|
|
t.Helper()
|
|
bild := schemaBild{spalten: map[string]string{}}
|
|
|
|
tabellen := abfrageListe(ctx, t, p,
|
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
|
for _, tabelle := range tabellen {
|
|
rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, tabelle)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for rows.Next() {
|
|
var name, typ string
|
|
var nichtNull, pk int
|
|
var standard sql.NullString
|
|
if err := rows.Scan(&name, &typ, &nichtNull, &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.
|
|
bild.spalten[tabelle+"."+name] = spaltenText(typ, nichtNull == 1 || pk > 0, standard)
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
// Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen
|
|
// für PRIMARY KEY und UNIQUE nicht.
|
|
bild.indizes = abfrageListe(ctx, t, p,
|
|
`SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`)
|
|
return bild
|
|
}
|
|
|
|
func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild {
|
|
t.Helper()
|
|
bild := schemaBild{spalten: 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 tabelle, name, typ, nullbar string
|
|
var standard sql.NullString
|
|
if err := rows.Scan(&tabelle, &name, &typ, &nullbar, &standard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bild.spalten[tabelle+"."+name] = spaltenText(typ, nullbar == "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.
|
|
bild.indizes = abfrageListe(ctx, t, p,
|
|
`SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE 'idx\_%' ORDER BY indexname`)
|
|
return bild
|
|
}
|
|
|
|
func spaltenText(typ string, nichtNull bool, standard sql.NullString) string {
|
|
text := typKlasse(typ)
|
|
if nichtNull {
|
|
text += " NOT NULL"
|
|
}
|
|
return text + standardwert(standard)
|
|
}
|
|
|
|
func abfrageListe(ctx context.Context, t *testing.T, p pool, abfrage string) []string {
|
|
t.Helper()
|
|
rows, err := p.QueryContext(ctx, abfrage)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var liste []string
|
|
for rows.Next() {
|
|
var s string
|
|
if err := rows.Scan(&s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
liste = append(liste, s)
|
|
}
|
|
return liste
|
|
}
|
|
|
|
func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) {
|
|
t.Helper()
|
|
|
|
alle := map[string]bool{}
|
|
for k := range sqlite.spalten {
|
|
alle[k] = true
|
|
}
|
|
for k := range postgres.spalten {
|
|
alle[k] = true
|
|
}
|
|
var namen []string
|
|
for k := range alle {
|
|
namen = append(namen, k)
|
|
}
|
|
sort.Strings(namen)
|
|
|
|
for _, name := range namen {
|
|
s, inSQLite := sqlite.spalten[name]
|
|
p, inPostgres := postgres.spalten[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.indizes) != fmt.Sprint(postgres.indizes) {
|
|
t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indizes, postgres.indizes)
|
|
}
|
|
}
|