Kern-Entitaeten (Dokument, Datei-Revision, Ordner, Tag, Metadatenfeld) als tenant-scoped SQL-Migration (Modell C, keine tenant_id-Spalte), FK auf users(id) aus Core IAM-01 (Auth bleibt vollstaendig in Core). Eigener, minimaler Migrations-Runner (internal/migrate, kein ORM) mit schema_migrations-Tracking fuer Idempotenz. Seed-Skript fuer Entwicklung. Auf 192.168.1.131 verifiziert: Migration auf leerer+bestehender DB, Rollback stellt Vorzustand wieder her, 4 FK-Negativtests, Seed-Skript end-to-end gegen frische DB. go.sum committet (Lock-Datei-Lehre aus dem Ticket). build/vet/lint/test clean. Siehe dms/docs/FDN-02-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
205 lines
6.4 KiB
Go
205 lines
6.4 KiB
Go
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")
|
|
}
|
|
|
|
version, err := DownOne(ctx, pool, migrations)
|
|
if err != nil {
|
|
t.Fatalf("downone: %v", err)
|
|
}
|
|
if version == "" {
|
|
t.Fatal("erwartet zurueckgerollte version, habe leeren string")
|
|
}
|
|
|
|
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 Rollback noch - Vorzustand nicht wiederhergestellt", table)
|
|
}
|
|
}
|
|
|
|
// Erneutes DownOne ohne verbleibende angewendete Migration liefert "".
|
|
version2, err := DownOne(ctx, pool, migrations)
|
|
if err != nil {
|
|
t.Fatalf("downone (nichts mehr anzuwenden): %v", err)
|
|
}
|
|
if version2 != "" {
|
|
t.Fatalf("erwartet leeren string bei leerer schema_migrations, habe %q", version2)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
})
|
|
}
|