TAR-01: Retention-Fristen-Migration ohne Reset #265
No files matched your search
@@ -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)
|
||||
```
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user