package migrate import ( "context" "os" "path/filepath" "testing" "github.com/jackc/pgx/v5/pgxpool" ) // usersFixtureSQL spiegelt Core IAM-01s reales Schema // (migrations/tenant/0001_users.up.sql im NEXARCH-Core-Modul) - DMS ist ein // eigenes Go-Modul und kann Cores Migrationsdateien nicht importieren, daher // hier als Testfixture kopiert, NICHT als Produktionsmigration (DMS legt // users nicht selbst an, siehe "Nicht Bestandteil" in FDN-02). const usersFixtureSQL = ` CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ` func setupTest(t *testing.T) (*pgxpool.Pool, []Migration) { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") if dsn == "" { t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(func() { pool.Close() }) if _, err := pool.Exec(ctx, usersFixtureSQL); err != nil { t.Fatalf("users-fixture anlegen: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO users (email, name) VALUES ('seed@example.test', 'Seed-Benutzer') ON CONFLICT (email) DO NOTHING `); err != nil { t.Fatalf("seed-benutzer anlegen: %v", err) } migrations, err := Load(migrationsDir(t)) if err != nil { t.Fatalf("migrationen laden: %v", err) } return pool, migrations } func migrationsDir(t *testing.T) string { t.Helper() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } return filepath.Join(wd, "..", "..", "migrations", "tenant") } func tableExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) bool { t.Helper() var exists bool if err := pool.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1) `, name).Scan(&exists); err != nil { t.Fatalf("tabellenexistenz von %q pruefen: %v", name, err) } return exists } // TestUp_OnEmptyAndExistingDB ist Pruefung 1: Migration auf leerer DB und // auf bestehender DB getestet. func TestUp_OnEmptyAndExistingDB(t *testing.T) { pool, migrations := setupTest(t) ctx := context.Background() applied, err := Up(ctx, pool, migrations) if err != nil { t.Fatalf("up (leere db): %v", err) } if len(applied) == 0 { t.Fatal("erwartet mindestens 1 angewendete migration auf leerer db") } for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values"} { if !tableExists(t, ctx, pool, table) { t.Fatalf("tabelle %q existiert nach Up nicht", table) } } // Zweiter Up-Lauf gegen die JETZT BESTEHENDE db - muss ohne Fehler // durchlaufen und darf nichts erneut anwenden (idempotent ueber // schema_migrations). appliedAgain, err := Up(ctx, pool, migrations) if err != nil { t.Fatalf("up (bestehende db, zweiter lauf): %v", err) } if len(appliedAgain) != 0 { t.Fatalf("zweiter Up-Lauf haette 0 neue migrationen anwenden sollen, hat %d", len(appliedAgain)) } } // TestDownOne_RestoresPreviousState ist Pruefung 2: Rollback stellt den // Vorzustand wieder her. func TestDownOne_RestoresPreviousState(t *testing.T) { pool, migrations := setupTest(t) ctx := context.Background() if _, err := Up(ctx, pool, migrations); err != nil { t.Fatalf("up: %v", err) } if !tableExists(t, ctx, pool, "documents") { t.Fatal("voraussetzung nicht erfuellt: documents sollte nach Up existieren") } // DownOne rollt IMMER nur die zuletzt angewendete Migration zurueck // (dokumentiertes Verhalten) — bei mehreren Migrationen (z.B. FDN-02 // documents + FDN-04 processing_jobs) muss man entsprechend oft // aufrufen. Anzahl der Wiederholungen richtet sich NICHT nach dem // Rueckgabewert von Up() (der nur die in DIESEM Aufruf NEU angewendeten // Migrationen zaehlt, siehe Kommentar an Up) — stattdessen wird // wiederholt, bis DownOne "" liefert (schema_migrations leer). for i := 0; i < len(migrations); i++ { version, err := DownOne(ctx, pool, migrations) if err != nil { t.Fatalf("downone (lauf %d): %v", i, err) } if version == "" { break } } for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values", "processing_jobs"} { if tableExists(t, ctx, pool, table) { t.Fatalf("tabelle %q existiert nach vollstaendigem Rollback noch - Vorzustand nicht wiederhergestellt", table) } } // Erneutes DownOne ohne verbleibende angewendete Migration liefert "". versionAfterAll, err := DownOne(ctx, pool, migrations) if err != nil { t.Fatalf("downone (nichts mehr anzuwenden): %v", err) } if versionAfterAll != "" { t.Fatalf("erwartet leeren string bei leerer schema_migrations, habe %q", versionAfterAll) } } // TestForeignKeyConstraints_RejectInvalidReferences ist Pruefung 3: // Fremdschluessel-Constraints durch Negativtests belegt. func TestForeignKeyConstraints_RejectInvalidReferences(t *testing.T) { pool, migrations := setupTest(t) ctx := context.Background() if _, err := Up(ctx, pool, migrations); err != nil { t.Fatalf("up: %v", err) } var userID string if err := pool.QueryRow(ctx, `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil { t.Fatalf("seed-benutzer lesen: %v", err) } t.Run("dokument mit unbekanntem ordner wird abgewiesen", func(t *testing.T) { _, err := pool.Exec(ctx, ` INSERT INTO documents (folder_id, title, created_by) VALUES (gen_random_uuid(), 'x', $1) `, userID) if err == nil { t.Fatal("insert mit unbekanntem folder_id haette scheitern muessen") } }) t.Run("dokument mit unbekanntem ersteller wird abgewiesen", func(t *testing.T) { _, err := pool.Exec(ctx, ` INSERT INTO documents (title, created_by) VALUES ('x', gen_random_uuid()) `) if err == nil { t.Fatal("insert mit unbekanntem created_by haette scheitern muessen") } }) t.Run("datei-revision mit unbekanntem dokument wird abgewiesen", func(t *testing.T) { _, err := pool.Exec(ctx, ` INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by) VALUES (gen_random_uuid(), 1, 'k', repeat('0',64), 1, 'text/plain', $1) `, userID) if err == nil { t.Fatal("insert mit unbekanntem document_id haette scheitern muessen") } }) t.Run("tag-zuordnung mit unbekanntem tag wird abgewiesen", func(t *testing.T) { var docID string if err := pool.QueryRow(ctx, ` INSERT INTO documents (title, created_by) VALUES ('fk-test-doc', $1) RETURNING id `, userID).Scan(&docID); err != nil { t.Fatalf("testdokument anlegen: %v", err) } _, err := pool.Exec(ctx, ` INSERT INTO document_tags (document_id, tag_id) VALUES ($1, gen_random_uuid()) `, docID) if err == nil { t.Fatal("insert mit unbekanntem tag_id haette scheitern muessen") } }) }