Postgres-Jobqueue (processing_jobs, FOR UPDATE SKIP LOCKED), In-Prozess- Worker-Goroutinen, kein Redis/AMQP. Enqueue mit Idempotency-Key-Dedup, Dequeue mit Stale-Lock-Wiedervorlage (Absturzsicherheit), Fail mit arithmetischem Backoff und Dead-Letter-Queue nach erschoepften Versuchen, RequeueDeadLetter fuer manuelle Wiederholung. Auf 192.168.1.131 verifiziert: Absturz-Wiedervorlage (Job von einem "abgestuerzten" Worker nie completed/failed, zweiter Worker holt ihn nach Ablauf der Sperre erneut), Idempotenz bei Doppelzustellung (gleicher idempotency_key erzeugt nur 1 Zeile), DLQ-Eintrag manuell wiederholbar. Vier reale Fehler beim Testen gefunden und behoben: zwei pgx-Typinferenz- Bugs im SQL-Parameterhandling (toter workerID-Parameter ohne Referenz in der Query; untypisiertes any statt []string fuer den ::text[]-Cast), sowie zwei Testinfrastruktur-Bugs (dms_tenant_test sammelte schema_migrations- Zustand ueber Sitzungen hinweg an, jobqueue-Testfixture raeumte processing_jobs nicht auf) - neues scripts/reset-test-env.sh + make check (-p 1) analog Core behoben. Siehe dms/docs/FDN-04-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
214 lines
6.9 KiB
Go
214 lines
6.9 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")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
})
|
|
}
|