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.
162 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|