Board-Entscheidung: Backend-API zuerst, echtes Next.js-Frontend als separates Folgeticket - vermeidet Pseudo-Frontend-Protokoll. internal/retentionapi: 4 Endpunkte (anlegen/aendern, deaktivieren, liste, vorschau), Vorschau nutzt dieselbe ListExpiringObjects-Funktion wie RET-02s periodischer Job (keine Doppel-Implementierung). RequireRole ist AUSDRUECKLICH kein RBAC-02-Ersatz, sondern ein dokumentiertes Provisorium (Header-Check) - RBAC-02 ist reiner Core-interner Go-Code ohne HTTP-Schnittstelle fuer andere Module, derselbe Befund wie FDN-03/FDN-09. Provisorium real getestet inkl. Negativfall (403 ohne/mit falscher Rolle). retention_class_rules um active-Flag erweitert (deaktivieren ohne Historienverlust). Real auf 131 deployed und per curl end-to-end verifiziert.
223 lines
7.7 KiB
Go
223 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|