TAR-01: Retention-Fristen-Migration ohne Reset #265

Merged
patrick merged 2 commits from feature/tar-01-retention-fristen-migration-ohne-reset into main 2026-10-14 17:29:38 +02:00
6 changed files with 362 additions and 0 deletions

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# TAR-01 – Prüfprotokoll: Retention-Fristen-Migration ohne Reset
Datum: 2026-10-14
Umgebung: Host 192.168.1.204, Postgres 17, eigene Wegwerf-Rolle/DB `tar01_test`/`tar01_testdb` (ausschließlich synthetische Fixtures, keine archivdms-/archivmail-Daten).
## Prüfung 1 – 50 synthetische Objekte, Frist in der Zukunft identisch
Test: `TestMigrateAll_PreservesFutureRetainUntilExactly` (`archive/internal/retentionmigration/retentionmigration_test.go`).
50 synthetische `dms_document`-Objekte mit je unterschiedlicher, in der Zukunft liegender `retain_until`-Frist wurden über `retentionmigration.Migrator.MigrateAll` migriert und anschließend je Objekt die in `retention_objects.retain_until` gespeicherte Frist mit der Quellfrist verglichen.
Ergebnis: PASS. Alle 50 Fristen nach Migration bitgenau identisch zur Quelle (`time.Time.Equal`).
## Prüfung 2 – Migration berechnet keine Frist aus dem aktuellen Datum
Test: `TestMigrateAll_ResultIndependentOfInjectedNow`.
Zwei Migrationsläufe gegen dieselbe Zieldatenbank, aber auf zwei voneinander unabhängigen Quellobjekten (`object_reference` `doc-clock-test-a` bzw. `doc-clock-test-b`, dieselbe Quellfrist `retain_until` = 2030-06-15), mit injiziertem `now`-Parameter, der künstlich um 73 Jahre auseinanderliegt (2026-01-01 vs. 2099-01-01). `now` fließt im Migrator ausschließlich in `Result.RanAt` (Protokoll-Metadatum) ein, nie in die Frist-Übernahme (`retention.SetRetainUntil` nimmt die Quellfrist unverändert).
Ergebnis: PASS. Beide (unabhängigen) Datensätze erhalten exakt dieselbe `retain_until`, unabhängig vom injizierten `now`. Gegenprobe durchgeführt: wird `now` testweise künstlich in die Fristberechnung eingebaut, schlägt der Test fehl (Vergleich `gotA.Equal(gotB)`), was belegt, dass der Test tatsächlich scharf ist; der Eingriff wurde danach wieder zurückgenommen und nicht committet.
## Prüfung 3 – Objekte ohne Quellfrist bleiben ohne Frist
Test: `TestMigrateAll_ObjectsWithoutSourceDeadlineStayWithoutOne`.
Ein synthetisches `mail_message`-Objekt ohne `RetainUntil` (nil) wurde migriert; `retention.RetainUntil` liefert danach `ok=false` (keine Frist gesetzt), kein Default-/Fallback-Wert.
Ergebnis: PASS.
## Migrations-Auf-/Abwärtskompatibilität (neue Spalte)
Migration `archive/migrations/0017_retention_objects_retain_until.{up,down}.sql` real gegen eine leere Datenbank ausgeführt: `up` fügt `retention_objects.retain_until TIMESTAMPTZ` (nullable) hinzu, `down` entfernt die Spalte wieder sauber. Manuell verifiziert per `psql \d retention_objects` vor/nach up und nach down.
## Gesamtergebnis
| Prüfung | Ergebnis |
|---|---|
| 1 – 50 Stichproben, Frist exakt identisch | PASS |
| 2 – keine Berechnung aus aktuellem Datum (zwei unabhängige Datensätze, unterschiedliches `now`) | PASS |
| 3 – keine Frist ohne Quellfrist | PASS |
| Bestehende `archive/internal/retention`-Tests weiterhin grün | PASS (3 PASS, 1 SKIP mangels zweiter Tenant-DSN, unverändert zum Vorzustand) |
| `go vet ./...` (archive-Modul) | clean |
| Migration 0017 up/down gegen leere DB | PASS |
Vollständiger Testlauf:
```
=== RUN TestMigration_ForwardAndBackward
--- PASS: TestMigration_ForwardAndBackward (0.45s)
=== RUN TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically
--- PASS: TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically (0.06s)
=== RUN TestAssignClass_IsHistoricized
--- PASS: TestAssignClass_IsHistoricized (0.05s)
=== RUN TestTenantIsolation_Negativtest
--- SKIP: TestTenantIsolation_Negativtest (0.00s)
=== RUN TestMigrateAll_PreservesFutureRetainUntilExactly
--- PASS: TestMigrateAll_PreservesFutureRetainUntilExactly (0.55s)
=== RUN TestMigrateAll_ObjectsWithoutSourceDeadlineStayWithoutOne
--- PASS: TestMigrateAll_ObjectsWithoutSourceDeadlineStayWithoutOne (0.03s)
=== RUN TestMigrateAll_ResultIndependentOfInjectedNow
--- PASS: TestMigrateAll_ResultIndependentOfInjectedNow (0.06s)
```
+32
View File
@@ -123,3 +123,35 @@ func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID str
}
return history, rows.Err()
}
// SetRetainUntil setzt retain_until direkt als UPDATE — keine Ableitung
// aus Klasse/Zuordnungszeitpunkt (siehe retentionengine.ComputeDueDate),
// damit eine 1:1 aus einem Altsystem übernommene Frist unverändert bleibt
// (TAR-01).
func SetRetainUntil(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string, retainUntil time.Time) error {
tag, err := pool.Exec(ctx, `
UPDATE retention_objects SET retain_until = $2 WHERE id = $1
`, retentionObjectID, retainUntil)
if err != nil {
return fmt.Errorf("retention: retain_until setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("retention: unbekanntes retention-objekt %q", retentionObjectID)
}
return nil
}
// RetainUntil liefert die gesetzte Frist, ok=false falls keine gesetzt ist.
func RetainUntil(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (retainUntil time.Time, ok bool, err error) {
var ru *time.Time
err = pool.QueryRow(ctx, `
SELECT retain_until FROM retention_objects WHERE id = $1
`, retentionObjectID).Scan(&ru)
if err != nil {
return time.Time{}, false, fmt.Errorf("retention: retain_until lesen: %w", err)
}
if ru == nil {
return time.Time{}, false, nil
}
return *ru, true, nil
}
@@ -0,0 +1,79 @@
// Package retentionmigration implementiert TAR-01: Übernahme von
// retain_until-Fristen aus den Altsystemen (bereits migrierte DOK-01-
// Dokumente und MAI-02-Nachrichten) in das generische Retention-Modell
// (archive/internal/retention, RET-01) — exakt 1:1, ohne Neuberechnung.
package retentionmigration
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
)
// SourceObject ist EIN bereits migriertes Kernobjekt (Dokument oder
// Nachricht) mit optionaler Frist aus der Quelle. RetainUntil == nil
// bedeutet: Objekt hatte in der Quelle keine Frist.
type SourceObject struct {
ObjectType string
ObjectReference string
RetainUntil *time.Time
}
// Source liefert die bereits migrierten Kernobjekte je Altsystem-Adapter
// (DOK-01/MAI-02 laufen vor diesem Schritt und liefern die ObjectReference).
type Source interface {
Objects(ctx context.Context) ([]SourceObject, error)
}
type Migrator struct {
pool *pgxpool.Pool
source Source
}
func NewMigrator(pool *pgxpool.Pool, source Source) *Migrator {
return &Migrator{pool: pool, source: source}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen. RanAt ist
// reine Protokoll-Metadaten (wann der Lauf stattfand) und fließt an
// keiner Stelle in die übernommenen retain_until-Werte ein.
type Result struct {
ObjectsRegistered int
RetentionDatesSet int
RanAt time.Time
}
// MigrateAll übernimmt retain_until 1:1 je Objekt (Akzeptanzkriterium 1),
// setzt keine Frist, wo die Quelle keine hatte (Akzeptanzkriterium 3), und
// berechnet an keiner Stelle aus now eine Frist (Akzeptanzkriterium 2) —
// now dient ausschließlich Result.RanAt.
func (m *Migrator) MigrateAll(ctx context.Context, now time.Time) (Result, error) {
res := Result{RanAt: now}
objs, err := m.source.Objects(ctx)
if err != nil {
return res, fmt.Errorf("retentionmigration: objekte aus quelle lesen: %w", err)
}
for _, obj := range objs {
id, err := retention.RegisterObject(ctx, m.pool, obj.ObjectType, obj.ObjectReference)
if err != nil {
return res, fmt.Errorf("retentionmigration: objekt %s/%s registrieren: %w", obj.ObjectType, obj.ObjectReference, err)
}
res.ObjectsRegistered++
if obj.RetainUntil == nil {
continue
}
if err := retention.SetRetainUntil(ctx, m.pool, id, *obj.RetainUntil); err != nil {
return res, fmt.Errorf("retentionmigration: frist fuer %s/%s uebernehmen: %w", obj.ObjectType, obj.ObjectReference, err)
}
res.RetentionDatesSet++
}
return res, nil
}
@@ -0,0 +1,187 @@
package retentionmigration
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
)
const schemaSQL = `
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(),
retain_until TIMESTAMPTZ,
UNIQUE (object_type, object_reference)
);
`
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, schemaSQL); err != nil {
t.Fatalf("schema: %v", err)
}
if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`); err != nil {
t.Fatalf("schema (retain_until nachruesten): %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_objects CASCADE`)
})
return pool
}
// fixtureSource ist eine SYNTHETISCHE In-Memory-Quelle: simuliert bereits
// migrierte DOK-01/MAI-02-Objekte, keine echten Altsystemdaten.
type fixtureSource struct {
objects []SourceObject
}
func (s *fixtureSource) Objects(ctx context.Context) ([]SourceObject, error) {
return s.objects, nil
}
func mustRetainUntil(t *testing.T, pool *pgxpool.Pool, retentionObjectID string) (time.Time, bool) {
t.Helper()
ru, ok, err := retention.RetainUntil(context.Background(), pool, retentionObjectID)
if err != nil {
t.Fatalf("retain_until lesen: %v", err)
}
return ru, ok
}
func objectID(t *testing.T, pool *pgxpool.Pool, objectType, objectReference string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2
`, objectType, objectReference).Scan(&id)
if err != nil {
t.Fatalf("objekt-id lesen: %v", err)
}
return id
}
// TestMigrateAll_PreservesFutureRetainUntilExactly ist Pruefung 1
// (Akzeptanzkriterium 1): Stichprobe von 50 synthetischen Objekten mit
// Frist in der Zukunft, Frist vor/nach Migration exakt identisch.
func TestMigrateAll_PreservesFutureRetainUntilExactly(t *testing.T) {
pool := requireTestPool(t)
const sampleSize = 50
var objs []SourceObject
want := map[string]time.Time{}
for i := 0; i < sampleSize; i++ {
ref := fmt.Sprintf("doc-%03d", i)
due := time.Date(2031, time.January, 1, 0, 0, 0, 0, time.UTC).
AddDate(0, 0, i).
Add(time.Duration(i) * time.Hour)
objs = append(objs, SourceObject{ObjectType: "dms_document", ObjectReference: ref, RetainUntil: &due})
want[ref] = due
}
m := NewMigrator(pool, &fixtureSource{objects: objs})
res, err := m.MigrateAll(context.Background(), time.Now())
if err != nil {
t.Fatalf("migrateall: %v", err)
}
if res.ObjectsRegistered != sampleSize || res.RetentionDatesSet != sampleSize {
t.Fatalf("result = %+v, want %d/%d", res, sampleSize, sampleSize)
}
for ref, due := range want {
id := objectID(t, pool, "dms_document", ref)
got, ok := mustRetainUntil(t, pool, id)
if !ok {
t.Fatalf("objekt %q: erwartet gesetzte frist, habe keine", ref)
}
if !got.Equal(due) {
t.Fatalf("objekt %q: frist nach migration = %v, quelle = %v", ref, got, due)
}
}
}
// TestMigrateAll_ObjectsWithoutSourceDeadlineStayWithoutOne ist Pruefung
// fuer Akzeptanzkriterium 3: Objekte ohne Frist in der Quelle bleiben
// nach der Migration ohne Frist.
func TestMigrateAll_ObjectsWithoutSourceDeadlineStayWithoutOne(t *testing.T) {
pool := requireTestPool(t)
m := NewMigrator(pool, &fixtureSource{objects: []SourceObject{
{ObjectType: "mail_message", ObjectReference: "msg-ohne-frist", RetainUntil: nil},
}})
res, err := m.MigrateAll(context.Background(), time.Now())
if err != nil {
t.Fatalf("migrateall: %v", err)
}
if res.ObjectsRegistered != 1 || res.RetentionDatesSet != 0 {
t.Fatalf("result = %+v, want 1 objekt registriert, 0 fristen gesetzt", res)
}
id := objectID(t, pool, "mail_message", "msg-ohne-frist")
if _, ok := mustRetainUntil(t, pool, id); ok {
t.Fatalf("objekt ohne quellfrist hat nach migration eine frist")
}
}
// TestMigrateAll_ResultIndependentOfInjectedNow ist Pruefung 2
// (Akzeptanzkriterium 2): zwei Migrationslaeufe mit weit auseinander
// liegenden "jetzt"-Zeitpunkten, auf zwei voneinander unabhaengigen
// Datensaetzen (unterschiedliche ObjectReference, gleiche Quellfrist),
// liefern IDENTISCHE retain_until-Werte - echter Nachweis statt
// Behauptung, dass now() an keiner Stelle in die berechnete Frist
// einfliesst. Wuerde now faelschlich einfliessen, muessten sich die
// beiden Ergebnisse unterscheiden (naeher am jeweils injizierten now).
func TestMigrateAll_ResultIndependentOfInjectedNow(t *testing.T) {
pool := requireTestPool(t)
due := time.Date(2030, time.June, 15, 12, 0, 0, 0, time.UTC)
nowA := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
nowB := time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC)
mA := NewMigrator(pool, &fixtureSource{objects: []SourceObject{
{ObjectType: "dms_document", ObjectReference: "doc-clock-test-a", RetainUntil: &due},
}})
if _, err := mA.MigrateAll(context.Background(), nowA); err != nil {
t.Fatalf("migrateall (now=A): %v", err)
}
mB := NewMigrator(pool, &fixtureSource{objects: []SourceObject{
{ObjectType: "dms_document", ObjectReference: "doc-clock-test-b", RetainUntil: &due},
}})
if _, err := mB.MigrateAll(context.Background(), nowB); err != nil {
t.Fatalf("migrateall (now=B): %v", err)
}
idA := objectID(t, pool, "dms_document", "doc-clock-test-a")
idB := objectID(t, pool, "dms_document", "doc-clock-test-b")
gotA, okA := mustRetainUntil(t, pool, idA)
gotB, okB := mustRetainUntil(t, pool, idB)
if !okA || !okB {
t.Fatalf("erwartet beide laeufe mit gesetzter frist, okA=%v okB=%v", okA, okB)
}
if !gotA.Equal(due) || !gotB.Equal(due) {
t.Fatalf("frist weicht von quelle ab: A=%v B=%v quelle=%v", gotA, gotB, due)
}
if !gotA.Equal(gotB) {
t.Fatalf("lauf mit now=%v lieferte andere frist als lauf mit now=%v: %v != %v", nowA, nowB, gotA, gotB)
}
}
@@ -0,0 +1 @@
ALTER TABLE retention_objects DROP COLUMN IF EXISTS retain_until;
@@ -0,0 +1 @@
ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ;