Files
nexarch/archive/internal/retentionengine/retentionengine_test.go
T
sysops 2c9a7482b6 feat(archive): RET-02 Aufbewahrungsfristen-Engine
internal/retentionengine: Frist je Aufbewahrungsklasse als natives
Postgres-INTERVAL, Stichtagsberechnung an Postgres delegiert statt
eigener Kalenderrechnung (Schaltjahr/Monatsende-Referenzwerte real
verifiziert: 2024-02-29+1y=2025-02-28, 2026-01-31+1mo=2026-02-28).
Periodischer Job (ListExpiringObjects) beschraenkt sich per DISTINCT ON
auf die juengste Klassenzuordnung je Objekt - sonst wuerden Objekte mit
mehrfach geaenderter Klasse (RET-01-Historisierung) doppelt auftauchen,
real mit einem Zwei-Zuordnungen-Testobjekt bewiesen. Scope bewusst eng
gehalten: keine RET-05-Anbindung, keine Vernichtungslogik - das ist
Ticket-Scope, dependsOn ist nur RET-01.
2026-08-30 01:54:48 +02:00

162 lines
5.4 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
);
`); 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)
}
})
}
}
// 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)
}
}
}