package retentionengine import ( "context" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func requireTestPool(t *testing.T) *pgxpool.Pool { 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, ` CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS retention_objects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, object_reference TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (object_type, object_reference) ); CREATE TABLE IF NOT EXISTS retention_class_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS retention_class_rules ( retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, active BOOLEAN NOT NULL DEFAULT true ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) }) return pool } func mustTime(t *testing.T, layout, value string) time.Time { t.Helper() tm, err := time.Parse(layout, value) if err != nil { t.Fatalf("zeitangabe parsen: %v", err) } return tm } // TestComputeDueDate_KnownReferenceDates ist Pruefung 1: Fristberechnung // an Referenzdaten mit bekannten Ablaufdaten geprueft - inklusive // Schaltjahr und Monatsende (Akzeptanzkriterium 2). Erwartete Werte real // gegen Postgres verifiziert (dessen eigene INTERVAL-Arithmetik ist die // Quelle der Wahrheit, keine eigene Nachbildung in Go). func TestComputeDueDate_KnownReferenceDates(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() if err := ConfigureClassRule(ctx, pool, "klasse-1-jahr", "1 year"); err != nil { t.Fatalf("regel konfigurieren: %v", err) } if err := ConfigureClassRule(ctx, pool, "klasse-1-monat", "1 month"); err != nil { t.Fatalf("regel konfigurieren: %v", err) } cases := []struct { name string start time.Time retentionClass string want time.Time }{ { name: "schaltjahr 29. februar plus 1 jahr", start: mustTime(t, "2006-01-02", "2024-02-29"), retentionClass: "klasse-1-jahr", want: mustTime(t, "2006-01-02", "2025-02-28"), }, { name: "monatsende 31. januar plus 1 monat", start: mustTime(t, "2006-01-02", "2026-01-31"), retentionClass: "klasse-1-monat", want: mustTime(t, "2006-01-02", "2026-02-28"), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := ComputeDueDate(ctx, pool, c.start, c.retentionClass) if err != nil { t.Fatalf("computeduedate: %v", err) } if !got.UTC().Equal(c.want.UTC()) { t.Fatalf("stichtag = %v, want %v", got, c.want) } }) } } // TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist // Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf // KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem // ein bereits berechneter Stichtag (in einer lokalen Variable, dem // einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt // existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden // koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine // NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet. func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() start := mustTime(t, "2006-01-02", "2026-01-01") if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil { t.Fatal(err) } before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung") if err != nil { t.Fatalf("erste berechnung: %v", err) } if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil { t.Fatal(err) } // Der bereits berechnete Wert (before) bleibt unveraendert - er ist // eine lokale Kopie, es existiert keine Tabelle, die eine // nachtraegliche "Umschreibung" ermoeglichen wuerde. if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) { t.Fatalf("bereits berechneter stichtag veraendert: %v", before) } after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung") if err != nil { t.Fatalf("zweite berechnung: %v", err) } if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) { t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after) } if before.Equal(after) { t.Fatal("neue frist haette eine andere berechnung liefern muessen") } } // TestDeactivateClassRule_ExcludesFromFutureCalculations. func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil { t.Fatal(err) } if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil { t.Fatalf("deactivateclassrule: %v", err) } if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil { t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden") } } // TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2. func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() got, err := ListExpiringObjects(ctx, pool, time.Now().UTC()) if err != nil { t.Fatalf("erwartet keinen fehler bei leerem bestand, habe: %v", err) } if len(got) != 0 { t.Fatalf("erwartet leere liste, habe %d eintraege", len(got)) } } // TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges ist // Pruefung 3: Mehrfachausfuehrung des Jobs erzeugt keine doppelten // Eintraege - real geprueft an einem Objekt mit MEHREREN historischen // Klassenzuordnungen (RET-01s Historisierung), das ohne die // DISTINCT-ON-Einschraenkung mehrfach im Ergebnis auftauchen wuerde. func TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() if err := ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil { t.Fatal(err) } var objID string if err := pool.QueryRow(ctx, ` INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'doc-mehrfach') RETURNING id `).Scan(&objID); err != nil { t.Fatalf("objekt anlegen: %v", err) } past := time.Now().UTC().Add(-72 * time.Hour) // zwei historische Zuordnungen fuer DASSELBE Objekt, beide in der // Vergangenheit (also beide laengst abgelaufen, wenn nicht auf die // juengste beschraenkt wuerde). if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past.Add(time.Hour)); err != nil { t.Fatal(err) } for run := 1; run <= 2; run++ { got, err := ListExpiringObjects(ctx, pool, time.Now().UTC()) if err != nil { t.Fatalf("lauf %d: %v", run, err) } if len(got) != 1 { t.Fatalf("lauf %d: erwartet genau 1 eintrag (kein duplikat trotz 2 historischer zuordnungen), habe %d: %+v", run, len(got), got) } } }