Compare commits

...
Author SHA1 Message Date
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
sysops 0db32007ba fix(archive): RET-05 retention_class fehlte, AC1 verlangt es explizit
Vor Board-Flip bemerkt: Akzeptanzkriterium 1 fordert Objekttyp MIT
Aufbewahrungsklasse UND Rueckruf-Adresse, retention_class fehlte im
ersten Entwurf komplett. Migration, Registration-Struct, Register,
ListRegistrations und RegisterHandler ergaenzt, Tests angepasst
(Idempotenz jetzt auch fuer retention_class geprueft, nicht nur
callback_url). Real auf 131 gedroppt und neu angewendet.
2026-08-30 01:43:33 +02:00
sysops 19dca43012 feat(archive): RET-05 Modul-Adapter-Schnittstelle (Interface-Freeze)
internal/moduleadapter: Registrierungs-API + Rueckruf-Ausloeser fuer
DMS/Mail, bewusst NUR Archives eigene Seite - keine Modul-Empfaenger-
Implementierung (Nutzervorgabe: Interface zuerst festlegen, damit
DMS/Mail spaeter nicht gegen ein sich noch aenderndes Interface bauen).
Register ist ON-CONFLICT-DO-NOTHING (erneute Registrierung aendert nie
bestehende callback_url), NotifyDestruction echter HTTP-POST mit festem
DestructionNotice-Vertrag. Idempotenz sowohl auf Go- als auch HTTP-
Ebene bewiesen, Rueckruf gegen echten Testendpunkt verifiziert.
2026-08-30 01:38:57 +02:00
12 changed files with 834 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# RET-02 Prüfprotokoll: Aufbewahrungsfristen-Engine
Voraussetzung RET-01 erledigt, siehe eigenes Protokoll.
**Scope-Klarstellung:** Dieses Ticket ist die Fristen-BERECHNUNGS-Engine
(Frist je Klasse, Stichtagsberechnung, periodischer Ablauf-Job) —
`dependsOn: ["RET-01"]`, KEINE Abhängigkeit auf RET-05. Die DMS-/Mail-
seitige Registrierung als RET-05-Adapter-Konsument sowie
Vernichtungs-Job-Fehlerbehandlung (2xx/Requeue) sind NICHT Teil dieser
Kachel — das ist ein späteres, eigenes Ticket (vermutlich im
ARC-*/DOC-*-Umfeld). Bewusst nicht mitgebaut, um nicht über den
Ticket-Umfang hinaus zu implementieren.
## Grundsatzentscheidung: Postgres-INTERVAL statt eigener Kalenderrechnung
`retention_class_rules.duration` ist ein natives Postgres-`INTERVAL`
(z. B. `'10 years'`, `'6 months'`) — `ComputeDueDate` delegiert die
gesamte Stichtagsberechnung an Postgres selbst (`start + duration`),
statt eine eigene Schaltjahr-/Monatsende-Logik in Go nachzubauen, die
von der WHERE-Klausel des periodischen Jobs (dieselbe Arithmetik)
abweichen könnte. Referenzwerte für Akzeptanzkriterium 2 real gegen
Postgres verifiziert, nicht angenommen:
`2024-02-29 + 1 year = 2025-02-28`, `2026-01-31 + 1 month = 2026-02-28`.
## Umsetzung
- `migrations/0004_retention_class_rules.up.sql`/`.down.sql`.
- `internal/retentionengine.ConfigureClassRule` — eine Regel je Klasse
(`UPSERT`).
- `internal/retentionengine.ComputeDueDate` — delegiert an Postgres.
- `internal/retentionengine.ListExpiringObjects` — periodischer Job:
`DISTINCT ON (retention_object_id)` auf die JÜNGSTE Klassenzuordnung
beschränkt, sonst würde ein Objekt mit mehrfach geänderter Klasse
(RET-01s Historisierung) mehrfach im Ergebnis auftauchen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Fristberechnung an Referenzdaten mit bekannten Ablaufdaten geprüft | **bestanden**`TestComputeDueDate_KnownReferenceDates`: Schaltjahr (29.02.2024 + 1 Jahr → 28.02.2025) und Monatsende (31.01.2026 + 1 Monat → 28.02.2026), beide Werte vorab real gegen Postgres verifiziert |
| 2 | Job liefert bei leerem Bestand ein leeres, nicht fehlerhaftes Ergebnis | **bestanden**`TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError` |
| 3 | Mehrfachausführung des Jobs erzeugt keine doppelten Einträge | **bestanden**`TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges`: Objekt mit ZWEI historischen Klassenzuordnungen (beide abgelaufen), zwei Job-Läufe liefern je genau 1 Eintrag — ohne die `DISTINCT ON`-Einschränkung wäre es 2 gewesen |
## Echte Verdrahtung auf 192.168.1.131
- Migration real gegen `dms_tenant_test` angewendet — `retention_class_rules`
bestätigt vorhanden neben `retention_objects`/`retention_class_assignments`
- Kein systemd-Timer in diesem Ticket — "periodischer Job" ist die
Bibliotheksfunktion `ListExpiringObjects`; ihr tatsächlicher
Aufrufer/Zeitplan (systemd-Timer + Meldeweg für abgelaufene Objekte)
ist Aufgabe eines Folgetickets, das auch die Vernichtungslogik selbst
bringt (dieses Ticket berechnet nur, wer fällig ist — vernichtet
nichts)
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 10/10 Pakete mit Tests ok, 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — Stichtagsberechnung gegen real
verifizierte Postgres-Referenzwerte, Job-Idempotenz gegen ein Objekt mit
echter Mehrfach-Historie bewiesen (nicht nur behauptet).
+94
View File
@@ -0,0 +1,94 @@
# RET-05 Prüfprotokoll: Modul-Adapter-Schnittstelle
Voraussetzung RET-01 erledigt, siehe eigenes Protokoll.
## Grundsatzentscheidung: Interface-Freeze, keine Modul-Implementierung
Nutzervorgabe: RET-05 als reines INTERFACE definieren (Registrierung,
Rückruf für Löschbestätigung, Fehlerverhalten) — NICHT schon
implementieren, damit spätere DMS-/Mail-Kacheln gegen ein bereits
feststehendes, nicht nachträglich verändertes Interface bauen. Dieses
Ticket liefert daher NUR Archives eigene Seite:
- Registrierungs-API (`internal/moduleadapter.Register` +
`RegisterHandler`, REST-Schnittstelle laut Ticket-Technikvorgabe).
- Rückruf-Auslöser (`NotifyDestruction`) mit feststehendem
Payload-Vertrag (`DestructionNotice`: `object_type`,
`object_reference`, `destroyed_at`).
**Bewusst NICHT Teil dieses Tickets**: die eigentlichen Rückruf-
EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) — die tatsächliche
Vernichtungslogik, die `NotifyDestruction` aufruft (kommt mit RET-02
und späteren Vernichtungs-Tickets), sowie Wiederholungslogik bei
fehlgeschlagenem Rückruf (Interface-Vertrag ist klar: Erfolg = HTTP
2xx, sonst Fehler — WIE mit einem Fehler umgegangen wird, ist
Aufgabe des aufrufenden Vernichtungs-Jobs, nicht dieses Pakets).
## Korrektur vor Abschluss: `retention_class` fehlte im ersten Entwurf
Akzeptanzkriterium 1 verlangt "Objekttyp MIT Aufbewahrungsklasse UND
Rückruf-Adresse" — der erste Entwurf von `module_registrations` und
`Register` hatte nur `callback_url`, `retention_class` fehlte komplett.
Vor dem Board-Flip auf „Fertig" bemerkt und korrigiert: Migration,
`Registration`-Struct, `Register`, `ListRegistrations` und
`RegisterHandler` um `retention_class` ergänzt, alle Tests entsprechend
angepasst (inkl. Idempotenz-Nachweis auch für `retention_class`, nicht
nur `callback_url`). Reale, bereits angewendete Migration auf
`dms_tenant_test` musste dafür gedroppt und neu angewendet werden (kein
Produktivbestand betroffen, Testsystem).
## Umsetzung
- `migrations/0003_module_registrations.up.sql`/`.down.sql`
`module_registrations` (module_name, object_type, callback_url,
UNIQUE-Constraint).
- `internal/moduleadapter.Register``ON CONFLICT DO NOTHING` + Nachlese
der bestehenden Zeile, damit eine erneute Registrierung NIE die
bestehende `callback_url` überschreibt (Akzeptanzkriterium 3).
- `internal/moduleadapter.ListRegistrations`.
- `internal/moduleadapter.NotifyDestruction` — echter HTTP-POST mit dem
festen `DestructionNotice`-Vertrag.
- `internal/moduleadapter.RegisterHandler` — REST-Endpunkt
(`POST /register`).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Zwei fiktive Module (DMS, Mail) parallel registriert ohne Kollision | **bestanden**`TestRegister_TwoModulesNoCollision`: unterschiedliche IDs, `ListRegistrations` zeigt beide |
| 2 | Rückruf bei Vernichtung erfolgreich gegen einen Testendpunkt ausgeführt | **bestanden**`TestNotifyDestruction_CallsRealTestEndpoint`: echter `httptest.Server`, echter POST, Payload real empfangen und geprüft (`object_reference` korrekt) |
| 3 | Erneute Registrierung desselben Objekttyps ändert nichts am bestehenden Zustand | **bestanden**`TestRegister_IsIdempotent_UnchangedExistingState` (Go-Funktion, mit absichtlich ABWEICHENDER `callback_url` im zweiten Aufruf) UND `TestRegisterHandler_RealHTTPRoundTrip` (dieselbe Prüfung nochmal über die HTTP-Schicht, nicht nur direkt gegen die Funktion) |
Zusätzlich: `TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus`
(Fehlerverhalten), `TestRegisterHandler_RejectsMissingFields`
(REST-Schicht weist unvollständige Registrierungen ab).
## Echte Verdrahtung auf 192.168.1.131
- Migration real gegen `dms_tenant_test` angewendet — `module_registrations`
bestätigt vorhanden
- Kein systemd-Dienst — `RegisterHandler` ist ein `http.HandlerFunc`,
wird in einen künftigen Core-/Archive-HTTP-Server eingehängt, sobald
ein solcher für Archive existiert (aktuell kein eigener Archive-
API-Server, nur die bisherigen CLI/Metrics-Prozesse) — dokumentierter,
kein stiller Gap, entspricht dem Interface-Freeze-Charakter dieses
Tickets
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 9/9 Pakete mit Tests ok, 0 Fehlschläge
(nach Korrektur; internal/retention und internal/moduleadapter brauchen
TEST_TENANT_DSN/TEST_TENANT_DSN_B bzw. TEST_TENANT_DSN)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — Idempotenz sowohl auf Go- als auch auf
HTTP-Ebene bewiesen, Rückruf-Vertrag gegen einen echten Testendpunkt
verifiziert. Bewusst als reiner Interface-Freeze umgesetzt, keine
DMS-/Mail-seitige Implementierung — wie vom Nutzer vorgegeben.
+46
View File
@@ -0,0 +1,46 @@
package moduleadapter
import (
"encoding/json"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
type registerRequest struct {
ModuleName string `json:"module_name"`
ObjectType string `json:"object_type"`
RetentionClass string `json:"retention_class"`
CallbackURL string `json:"callback_url"`
}
// RegisterHandler ist die REST-Schnittstelle (Ticket-Technikvorgabe), über
// die ein Modul einen Objekttyp registriert (Akzeptanzkriterium 1).
// POST /register mit JSON-Body {module_name, object_type, callback_url}.
func RegisterHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.ModuleName == "" || req.ObjectType == "" || req.RetentionClass == "" || req.CallbackURL == "" {
http.Error(w, "module_name, object_type, retention_class und callback_url sind pflichtfelder", http.StatusBadRequest)
return
}
reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.RetentionClass, req.CallbackURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(reg)
}
}
@@ -0,0 +1,67 @@
package moduleadapter
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestRegisterHandler_RealHTTPRoundTrip beweist die REST-Schnittstelle
// (Ticket-Technikvorgabe) end-to-end: echter HTTP-Server, echter POST,
// zweiter POST mit abweichender callback_url aendert nichts (Pruefung 3
// auch ueber die HTTP-Schicht, nicht nur die Go-Funktion direkt).
func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
server := httptest.NewServer(RegisterHandler(pool))
defer server.Close()
post := func(body registerRequest) (int, Registration) {
t.Helper()
data, _ := json.Marshal(body)
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var reg Registration
if resp.StatusCode == http.StatusOK {
if err := json.NewDecoder(resp.Body).Decode(&reg); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
}
return resp.StatusCode, reg
}
status1, reg1 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-original", CallbackURL: "https://dms.example.test/original"})
if status1 != http.StatusOK {
t.Fatalf("erster post: status = %d, want 200", status1)
}
status2, reg2 := post(registerRequest{ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-ANDERE", CallbackURL: "https://dms.example.test/andere"})
if status2 != http.StatusOK {
t.Fatalf("zweiter post: status = %d, want 200", status2)
}
if reg2.ID != reg1.ID || reg2.CallbackURL != "https://dms.example.test/original" {
t.Fatalf("zweiter post veraenderte bestehenden zustand: %+v, erster war %+v", reg2, reg1)
}
}
// TestRegisterHandler_RejectsMissingFields ist Nachweis des
// Fehlerverhaltens auf der REST-Schicht.
func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
pool := requireTestPool(t)
server := httptest.NewServer(RegisterHandler(pool))
defer server.Close()
data, _ := json.Marshal(registerRequest{ModuleName: "dms"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 bei fehlenden pflichtfeldern", resp.StatusCode)
}
}
@@ -0,0 +1,121 @@
// Package moduleadapter implementiert RET-05: die Schnittstelle, über
// die DMS und Mail ihre Objekttypen bei Archive registrieren, statt
// eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND
// ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die
// eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte)
// sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln
// gegen ein bereits feststehendes, nicht nachträglich verändertes
// Interface bauen (Nutzervorgabe).
package moduleadapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registration ist EIN registrierter Objekttyp eines Moduls.
type Registration struct {
ID string
ModuleName string
ObjectType string
RetentionClass string
CallbackURL string
}
// Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse
// für Löschbestätigung — mandantenfähig durch Modell C (physisch
// isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne
// von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN
// Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben
// der callback_url) — anders als RET-01s RegisterObject (dort
// UPSERT-artig), weil ein bereits registrierter Rückruf nicht
// stillschweigend durch eine zweite, möglicherweise abweichende
// Registrierung ersetzt werden darf.
func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) {
var reg Registration
err := pool.QueryRow(ctx, `
INSERT INTO module_registrations (module_name, object_type, retention_class, callback_url)
VALUES ($1, $2, $3, $4)
ON CONFLICT (module_name, object_type) DO NOTHING
RETURNING id, module_name, object_type, retention_class, callback_url
`, moduleName, objectType, retentionClass, callbackURL).Scan(&reg.ID, &reg.ModuleName, &reg.ObjectType, &reg.RetentionClass, &reg.CallbackURL)
if err == nil {
return reg, nil
}
// ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) -
// bestehende Registrierung unveraendert nachlesen und zurueckgeben.
err = pool.QueryRow(ctx, `
SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations
WHERE module_name = $1 AND object_type = $2
`, moduleName, objectType).Scan(&reg.ID, &reg.ModuleName, &reg.ObjectType, &reg.RetentionClass, &reg.CallbackURL)
if err != nil {
return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err)
}
return reg, nil
}
// ListRegistrations liefert alle registrierten Objekttypen — Grundlage
// für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module
// parallel registriert ohne Kollision).
func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) {
rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations ORDER BY module_name, object_type`)
if err != nil {
return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err)
}
defer rows.Close()
var regs []Registration
for rows.Next() {
var r Registration
if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.RetentionClass, &r.CallbackURL); err != nil {
return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err)
}
regs = append(regs, r)
}
return regs, rows.Err()
}
// DestructionNotice ist der Rückruf-Payload bei Vernichtung eines
// Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das
// jeder Modul-Rückruf-Empfänger erwarten muss.
type DestructionNotice struct {
ObjectType string `json:"object_type"`
ObjectReference string `json:"object_reference"`
DestroyedAt time.Time `json:"destroyed_at"`
}
// NotifyDestruction ruft das registrierte Modul beim Vernichten eines
// Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste
// (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den
// Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener
// Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss
// (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der
// Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler).
func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error {
body, err := json.Marshal(notice)
if err != nil {
return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,154 @@
package moduleadapter
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func jsonDecode(r *http.Request, v interface{}) error {
defer func() { _ = r.Body.Close() }()
return json.NewDecoder(r.Body).Decode(v)
}
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 module_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), module_name TEXT NOT NULL,
object_type TEXT NOT NULL, retention_class TEXT NOT NULL, callback_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (module_name, object_type)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) })
return pool
}
// TestRegister_TwoModulesNoCollision ist Pruefung 1: zwei fiktive Module
// (DMS, Mail) parallel registriert ohne Kollision.
func TestRegister_TwoModulesNoCollision(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
dmsReg, err := Register(ctx, pool, "dms", "document", "klasse-B2B-standard", "https://dms.example.test/callback")
if err != nil {
t.Fatalf("dms registrieren: %v", err)
}
mailReg, err := Register(ctx, pool, "mail", "message", "klasse-korrespondenz", "https://mail.example.test/callback")
if err != nil {
t.Fatalf("mail registrieren: %v", err)
}
if dmsReg.ID == mailReg.ID {
t.Fatal("dms und mail erhielten dieselbe id - kollision")
}
all, err := ListRegistrations(ctx, pool)
if err != nil {
t.Fatalf("listregistrations: %v", err)
}
if len(all) != 2 {
t.Fatalf("erwartet 2 registrierungen, habe %d", len(all))
}
}
// TestRegister_IsIdempotent_UnchangedExistingState ist Pruefung 3:
// erneute Registrierung desselben Objekttyps aendert NICHTS am
// bestehenden Zustand - auch nicht bei abweichender callback_url im
// zweiten Aufruf.
func TestRegister_IsIdempotent_UnchangedExistingState(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
first, err := Register(ctx, pool, "dms", "document", "klasse-original", "https://dms.example.test/original")
if err != nil {
t.Fatalf("erste registrierung: %v", err)
}
second, err := Register(ctx, pool, "dms", "document", "klasse-ANDERE", "https://dms.example.test/ANDERE-url")
if err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
if second.ID != first.ID {
t.Fatalf("erneute registrierung erzeugte neue id: %q, want %q", second.ID, first.ID)
}
if second.CallbackURL != "https://dms.example.test/original" {
t.Fatalf("callback_url wurde ueberschrieben: %q, want unveraendert %q", second.CallbackURL, first.CallbackURL)
}
if second.RetentionClass != "klasse-original" {
t.Fatalf("retention_class wurde ueberschrieben: %q, want unveraendert %q", second.RetentionClass, "klasse-original")
}
all, err := ListRegistrations(ctx, pool)
if err != nil {
t.Fatalf("listregistrations: %v", err)
}
if len(all) != 1 {
t.Fatalf("erwartet weiterhin genau 1 registrierung, habe %d", len(all))
}
}
// TestNotifyDestruction_CallsRealTestEndpoint ist Pruefung 2: Rueckruf
// bei Vernichtung erfolgreich gegen einen Testendpunkt ausgefuehrt -
// echter HTTP-Server, echter Request, echte Payload-Pruefung.
func TestNotifyDestruction_CallsRealTestEndpoint(t *testing.T) {
var receivedNotice DestructionNotice
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodPost {
t.Errorf("erwartet POST, habe %s", r.Method)
}
if err := jsonDecode(r, &receivedNotice); err != nil {
t.Errorf("payload dekodieren: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
notice := DestructionNotice{ObjectType: "document", ObjectReference: "doc-789", DestroyedAt: time.Now().UTC()}
if err := NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil {
t.Fatalf("notifydestruction: %v", err)
}
if !called {
t.Fatal("testendpunkt wurde nie aufgerufen")
}
if receivedNotice.ObjectReference != "doc-789" {
t.Fatalf("empfangene objekt-referenz = %q, want doc-789", receivedNotice.ObjectReference)
}
}
// TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus ist Nachweis des
// Fehlerverhaltens: ein fehlschlagender Rueckruf wird als Fehler
// gemeldet, nicht verschluckt.
func TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
err := NotifyDestruction(context.Background(), server.Client(), server.URL, DestructionNotice{})
if err == nil {
t.Fatal("erwartet fehler bei 500-antwort des rueckruf-endpunkts")
}
}
@@ -0,0 +1,96 @@
// Package retentionengine implementiert RET-02: Fristenmodell je
// Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job,
// der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects,
// retention_class_assignments) auf — kennt weiter keine Modul-Interna
// (dieselbe Adapter-Disziplin).
package retentionengine
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years",
// "6 months") für eine Aufbewahrungsklasse fest oder ändert sie
// (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel.
func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error {
_, err := pool.Exec(ctx, `
INSERT INTO retention_class_rules (retention_class, duration)
VALUES ($1, $2::interval)
ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration
`, retentionClass, duration)
if err != nil {
return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err)
}
return nil
}
// ComputeDueDate berechnet den Stichtag aus Beginn (start) und der
// konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
var due time.Time
err := pool.QueryRow(ctx, `
SELECT $1::timestamptz + r.duration
FROM retention_class_rules r WHERE r.retention_class = $2
`, start, retentionClass).Scan(&due)
if err != nil {
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
}
return due, nil
}
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
type ExpiringObject struct {
RetentionObjectID string
ObjectType string
ObjectReference string
RetentionClass string
DueDate time.Time
}
// ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3):
// liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle
// Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je
// Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) -
// ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter
// Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen,
// genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt.
// Ein leerer Bestand liefert eine leere Liste, keinen Fehler
// (Akzeptanzkriterium/Pflichtprüfung 2).
func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) {
rows, err := pool.Query(ctx, `
WITH latest_assignment AS (
SELECT DISTINCT ON (retention_object_id)
retention_object_id, retention_class, assigned_at
FROM retention_class_assignments
ORDER BY retention_object_id, assigned_at DESC
)
SELECT o.id, o.object_type, o.object_reference, a.retention_class,
a.assigned_at + r.duration AS due_date
FROM retention_objects o
JOIN latest_assignment a ON a.retention_object_id = o.id
JOIN retention_class_rules r ON r.retention_class = a.retention_class
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
ORDER BY due_date ASC
`, asOf)
if err != nil {
return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err)
}
defer rows.Close()
var out []ExpiringObject
for rows.Next() {
var e ExpiringObject
if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil {
return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
@@ -0,0 +1,161 @@
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)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS module_registrations;
@@ -0,0 +1,15 @@
-- RET-05: Modul-Adapter-Schnittstelle. Ein Modul (DMS, Mail, ...)
-- registriert je Objekttyp EINE Rueckruf-Adresse fuer Loeschbestaetigung
-- - Archive kennt danach nur noch module_name/object_type/callback_url,
-- keine Modul-Interna. Mandantenfaehig durch Modell C (physisch
-- isolierte Tenant-DB, TEN-01) - kein tenant_id-Feld noetig, dieselbe
-- Begruendung wie RET-01s retention_objects.
CREATE TABLE IF NOT EXISTS module_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_name TEXT NOT NULL,
object_type TEXT NOT NULL,
retention_class TEXT NOT NULL,
callback_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (module_name, object_type)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS retention_class_rules;
@@ -0,0 +1,9 @@
-- RET-02: Fristenmodell je Aufbewahrungsklasse. duration ist ein
-- natives Postgres-INTERVAL statt eigener Tage-/Monatszaehlung, damit
-- Kalenderfaelle (Schaltjahr, Monatsende) exakt Postgres' eigene,
-- bewaehrte Intervall-Arithmetik nutzen statt eine eigene, potenziell
-- fehlerhafte Nachbildung.
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY,
duration INTERVAL NOT NULL
);