VAL-03: Retention-Fristen-Verifikation #277
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package retentionverify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderBericht erzeugt den deutschsprachigen Fristen-Pruefbericht als
|
||||
// Markdown-Tabelle, sortiert nach Tenant und Objektreferenz.
|
||||
func RenderBericht(results []SampleResult) string {
|
||||
sorted := append([]SampleResult(nil), results...)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if sorted[i].TenantSlug != sorted[j].TenantSlug {
|
||||
return sorted[i].TenantSlug < sorted[j].TenantSlug
|
||||
}
|
||||
if sorted[i].ObjectType != sorted[j].ObjectType {
|
||||
return sorted[i].ObjectType < sorted[j].ObjectType
|
||||
}
|
||||
return sorted[i].ObjectReference < sorted[j].ObjectReference
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Retention-Fristen-Verifikation (Stichprobe)\n\n")
|
||||
b.WriteString("| Tenant | Objekttyp | Objektreferenz | Frist Quelle | Frist Ziel | Status |\n")
|
||||
b.WriteString("|---|---|---|---|---|---|\n")
|
||||
for _, r := range sorted {
|
||||
status := "OK"
|
||||
ziel := "-"
|
||||
switch {
|
||||
case !r.TargetFound:
|
||||
status = "OFFENER BEFUND (keine frist im ziel)"
|
||||
case !r.Match:
|
||||
status = "OFFENER BEFUND (frist weicht ab)"
|
||||
}
|
||||
if r.TargetFound {
|
||||
ziel = r.TargetRetainUntil.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n",
|
||||
r.TenantSlug, r.ObjectType, r.ObjectReference,
|
||||
r.SourceRetainUntil.Format("2006-01-02T15:04:05Z07:00"), ziel, status)
|
||||
}
|
||||
|
||||
offene := OffeneBefunde(results)
|
||||
fmt.Fprintf(&b, "\nStichprobe: %d Objekte, %d offene Befunde.\n", len(results), len(offene))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package retentionverify
|
||||
|
||||
// OffeneBefunde filtert alle Ergebnisse mit ungeklaerter Abweichung
|
||||
// (Akzeptanzkriterium 2).
|
||||
func OffeneBefunde(results []SampleResult) []SampleResult {
|
||||
var out []SampleResult
|
||||
for _, r := range results {
|
||||
if r.OffenerBefund() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package retentionverify implementiert VAL-03: Stichprobenvergleich von
|
||||
// retain_until (Quelle aus TAR-01 vs. nach der Migration im generischen
|
||||
// Retention-Objektmodell) je Tenant, nach dem Berichtsmuster aus VAL-01
|
||||
// (internal/migrationverify): Abweichung ist entweder erklaert oder
|
||||
// blockierender Befund, nie reine Notiz.
|
||||
package retentionverify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SourceSample ist EIN Objekt aus der Quelle mit Frist in der Zukunft,
|
||||
// Teil der Stichprobe fuer einen Tenant.
|
||||
type SourceSample struct {
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
RetainUntil time.Time
|
||||
}
|
||||
|
||||
// SampleSource liefert die Stichprobe von Objekten mit retain_until in
|
||||
// der Zukunft aus der Quelle (Altsystem bzw. dessen Exportfixture) fuer
|
||||
// einen Tenant.
|
||||
type SampleSource interface {
|
||||
FutureRetentionSamples(ctx context.Context, n int) ([]SourceSample, error)
|
||||
}
|
||||
|
||||
// SampleResult ist das Ergebnis EINES Stichprobenvergleichs
|
||||
// (Akzeptanzkriterium 1).
|
||||
type SampleResult struct {
|
||||
TenantSlug string
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
SourceRetainUntil time.Time
|
||||
TargetRetainUntil time.Time
|
||||
TargetFound bool
|
||||
Match bool
|
||||
}
|
||||
|
||||
// OffenerBefund ist true, wenn die Frist nach der Migration fehlt oder
|
||||
// von der Quelle abweicht - JEDE Abweichung gilt als blockierender
|
||||
// Befund, nicht als reine Notiz (Akzeptanzkriterium 2).
|
||||
func (r SampleResult) OffenerBefund() bool {
|
||||
return !r.TargetFound || !r.Match
|
||||
}
|
||||
|
||||
func lookupRetentionObjectID(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, bool, error) {
|
||||
var id string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2
|
||||
`, objectType, objectReference).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("retentionverify: retention-objekt %s/%s nachschlagen: %w", objectType, objectReference, err)
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
func lookupRetainUntil(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (time.Time, bool, 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("retentionverify: retain_until lesen: %w", err)
|
||||
}
|
||||
if ru == nil {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return *ru, true, nil
|
||||
}
|
||||
|
||||
// CompareSamples vergleicht je Sample die uebernommene retain_until
|
||||
// gegen die Quelle (Akzeptanzkriterium 1, Pruefung 2: eine absichtlich
|
||||
// verschobene Testfrist wird erkannt).
|
||||
func CompareSamples(ctx context.Context, pool *pgxpool.Pool, tenantSlug string, samples []SourceSample) ([]SampleResult, error) {
|
||||
results := make([]SampleResult, 0, len(samples))
|
||||
for _, s := range samples {
|
||||
r := SampleResult{
|
||||
TenantSlug: tenantSlug,
|
||||
ObjectType: s.ObjectType,
|
||||
ObjectReference: s.ObjectReference,
|
||||
SourceRetainUntil: s.RetainUntil,
|
||||
}
|
||||
|
||||
id, found, err := lookupRetentionObjectID(ctx, pool, s.ObjectType, s.ObjectReference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
results = append(results, r)
|
||||
continue
|
||||
}
|
||||
|
||||
ru, ok, err := lookupRetainUntil(ctx, pool, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.TargetFound = ok
|
||||
r.TargetRetainUntil = ru
|
||||
r.Match = ok && ru.Equal(s.RetainUntil)
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Run zieht je Tenant eine Stichprobe aus der jeweiligen Quelle und
|
||||
// vergleicht sie gegen das Ziel.
|
||||
func Run(ctx context.Context, pool *pgxpool.Pool, tenantSlug string, source SampleSource, sampleSize int) ([]SampleResult, error) {
|
||||
samples, err := source.FutureRetentionSamples(ctx, sampleSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionverify: stichprobe fuer tenant %q aus quelle lesen: %w", tenantSlug, err)
|
||||
}
|
||||
return CompareSamples(ctx, pool, tenantSlug, samples)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package retentionverify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
// migratedFixture registriert die Zielseite (bereits per TAR-01 migrierte
|
||||
// Objekte) direkt ueber retention.RegisterObject/SetRetainUntil - echte
|
||||
// Testobjekte, unabhaengig von der Quell-Stichprobe, kein geteilter
|
||||
// Datensatz ueber zwei Pools (vermeidet den TAR-01-Testfehler).
|
||||
func migrateIntoTarget(t *testing.T, pool *pgxpool.Pool, objectType, objectReference string, retainUntil *time.Time) {
|
||||
t.Helper()
|
||||
id, err := retention.RegisterObject(context.Background(), pool, objectType, objectReference)
|
||||
if err != nil {
|
||||
t.Fatalf("zielobjekt registrieren: %v", err)
|
||||
}
|
||||
if retainUntil == nil {
|
||||
return
|
||||
}
|
||||
if err := retention.SetRetainUntil(context.Background(), pool, id, *retainUntil); err != nil {
|
||||
t.Fatalf("zielfrist setzen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareSamples_RepraesentativeStichprobeOhneAbweichung ist Pruefung
|
||||
// 1 (Akzeptanzkriterium 1, Pruefung vor Abnahme 1): Stichprobe je Tenant
|
||||
// erfolgreich verglichen, keine Abweichung. In der Testumgebung reichen
|
||||
// 20 statt 50 Objekte - synthetische Fristen aus einer Schleife
|
||||
// generiert, 50 waeren in diesem Integrationstest nur mehr Laufzeit ohne
|
||||
// zusaetzlichen Erkenntniswert (Produktionsvorgabe bleibt 50 je Tenant).
|
||||
func TestCompareSamples_RepraesentativeStichproveOhneAbweichung(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
const sampleSize = 20
|
||||
tenantSlug := "mandant-a"
|
||||
|
||||
var samples []SourceSample
|
||||
for i := 0; i < sampleSize; i++ {
|
||||
ref := fmt.Sprintf("doc-val03-%03d", i)
|
||||
due := time.Date(2032, time.January, 1, 0, 0, 0, 0, time.UTC).
|
||||
AddDate(0, 0, i).Add(time.Duration(i) * time.Hour)
|
||||
migrateIntoTarget(t, pool, "dms_document", ref, &due)
|
||||
samples = append(samples, SourceSample{ObjectType: "dms_document", ObjectReference: ref, RetainUntil: due})
|
||||
}
|
||||
|
||||
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("comparesamples: %v", err)
|
||||
}
|
||||
if len(results) != sampleSize {
|
||||
t.Fatalf("len(results) = %d, want %d", len(results), sampleSize)
|
||||
}
|
||||
if offene := OffeneBefunde(results); len(offene) != 0 {
|
||||
t.Fatalf("erwartet keine offenen befunde, habe %d: %+v", len(offene), offene)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Match || !r.TargetFound {
|
||||
t.Fatalf("objekt %q: erwartet match, habe %+v", r.ObjectReference, r)
|
||||
}
|
||||
}
|
||||
|
||||
bericht := RenderBericht(results)
|
||||
if !strings.Contains(bericht, tenantSlug) || strings.Contains(bericht, "OFFENER BEFUND") {
|
||||
t.Fatalf("bericht enthaelt unerwarteten befund:\n%s", bericht)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt ist
|
||||
// Pruefung 2 (Pruefung vor Abnahme 2): eine absichtlich verschobene
|
||||
// Testfrist wird vom Vergleich erkannt - und zwar als blockierender
|
||||
// Befund, nicht als reine Notiz (Akzeptanzkriterium 2).
|
||||
func TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
tenantSlug := "mandant-b"
|
||||
|
||||
korrekteFrist := time.Date(2033, time.March, 1, 0, 0, 0, 0, time.UTC)
|
||||
migrateIntoTarget(t, pool, "mail_message", "msg-val03-verschoben", &korrekteFrist)
|
||||
|
||||
verschobeneQuellfrist := korrekteFrist.Add(48 * time.Hour)
|
||||
samples := []SourceSample{
|
||||
{ObjectType: "mail_message", ObjectReference: "msg-val03-verschoben", RetainUntil: verschobeneQuellfrist},
|
||||
}
|
||||
|
||||
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("comparesamples: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.Match {
|
||||
t.Fatalf("verschobene frist haette nicht als match erkannt werden duerfen: %+v", r)
|
||||
}
|
||||
if !r.OffenerBefund() {
|
||||
t.Fatalf("verschobene frist muss als offener befund gelten (kein reines logging): %+v", r)
|
||||
}
|
||||
offene := OffeneBefunde(results)
|
||||
if len(offene) != 1 {
|
||||
t.Fatalf("erwartet genau einen offenen befund, habe %d", len(offene))
|
||||
}
|
||||
|
||||
bericht := RenderBericht(results)
|
||||
if !strings.Contains(bericht, "OFFENER BEFUND") {
|
||||
t.Fatalf("bericht markiert verschobene frist nicht als offenen befund:\n%s", bericht)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareSamples_FehlendesZielobjektIstOffenerBefund deckt den Fall
|
||||
// ab, dass ein Quellobjekt im Ziel gar nicht registriert wurde - auch
|
||||
// das ist ein Befund, keine stille Luecke.
|
||||
func TestCompareSamples_FehlendesZielobjektIstOffenerBefund(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
tenantSlug := "mandant-c"
|
||||
|
||||
samples := []SourceSample{
|
||||
{ObjectType: "dms_document", ObjectReference: "doc-val03-fehlt-im-ziel", RetainUntil: time.Date(2034, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
results, err := CompareSamples(context.Background(), pool, tenantSlug, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("comparesamples: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].TargetFound {
|
||||
t.Fatalf("erwartet targetfound=false, habe %+v", results)
|
||||
}
|
||||
if !results[0].OffenerBefund() {
|
||||
t.Fatal("fehlendes zielobjekt muss als offener befund gelten")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# VAL-03 – Pruefprotokoll: Retention-Fristen-Verifikation
|
||||
|
||||
## Auftrag
|
||||
|
||||
Fuer eine Stichprobe von Objekten mit `retain_until` in der Zukunft wird
|
||||
geprueft, dass die Frist nach der Migration exakt identisch mit der Quelle
|
||||
ist — direkte Verifikation von TAR-01, dem GoBD-kritischsten
|
||||
Migrationsschritt. Kein Tenant wird freigeschaltet, dessen Verifikation
|
||||
nicht schriftlich vorliegt.
|
||||
|
||||
## Gelesener Code der Vorbedingungen
|
||||
|
||||
- `archive/internal/retentionmigration/retentionmigration.go` (TAR-01):
|
||||
uebernimmt `retain_until` 1:1 aus der Quelle (`Source.Objects`) ins
|
||||
generische Retention-Objektmodell, ohne Neuberechnung aus `now`.
|
||||
TAR-01 hatte einen methodischen Testfehler: der
|
||||
"now-Unabhaengigkeits"-Test lief gegen zwei Objekte im selben
|
||||
Pool/Datensatz statt echter unabhaengiger Datenstichproben — in
|
||||
diesem Ticket bewusst vermieden (siehe Pruefung 1 unten: Ziel wird
|
||||
direkt ueber `retention.RegisterObject`/`SetRetainUntil` befuellt,
|
||||
die Quell-Stichprobe ist ein unabhaengig konstruierter `[]SourceSample`
|
||||
-Datensatz, kein geteilter Pool-Zustand).
|
||||
- `archive/internal/retention/retention.go` (RET-01): `SetRetainUntil`/
|
||||
`RetainUntil` sind reine UPDATE/SELECT-Operationen auf
|
||||
`retention_objects.retain_until`, keine Ableitung aus Klasse/Zeitpunkt.
|
||||
- `internal/migrationverify` (VAL-01): Berichtsmuster uebernommen —
|
||||
`SampleResult`/`OffenerBefund()`-Konzept (Abweichung ist entweder
|
||||
erklaert oder blockierender Befund), `OffeneBefunde`-Filter,
|
||||
`RenderBericht` als deutschsprachige Markdown-Tabelle.
|
||||
|
||||
## Architekturentscheidung
|
||||
|
||||
Neues Paket `archive/internal/retentionverify`, im selben Go-Modul wie
|
||||
`retention`/`retentionmigration` (kein Modul-Grenzproblem wie bei VAL-01
|
||||
dms/mail). Kleinste Loesung:
|
||||
|
||||
- `SourceSample` (ObjectType, ObjectReference, RetainUntil) und
|
||||
`SampleSource`-Interface (`FutureRetentionSamples(ctx, n)`) als
|
||||
generischer Quelladapter — analog zu `retentionmigration.Source`,
|
||||
bewusst nicht direkt wiederverwendet, da TAR-01s `Source.Objects`
|
||||
ALLE Objekte liefert (auch ohne Frist), VAL-03 aber nur die
|
||||
Zukunfts-Stichprobe braucht. Konkrete Produktiv-Adapter (echter
|
||||
SQL-Export aus archivdms/archivmail) folgen in einer Folge-Kachel,
|
||||
analog zum offenen Punkt aus VAL-01.
|
||||
- `CompareSamples` schlaegt je Sample das Ziel-Retention-Objekt ueber
|
||||
`object_type`/`object_reference` nach (`retention_objects`) und
|
||||
vergleicht `retain_until` exakt (`time.Equal`) — kein fehlendes
|
||||
Zielobjekt und keine Abweichung bleibt unbemerkt:
|
||||
`SampleResult.OffenerBefund()` ist `true`, sobald das Objekt im Ziel
|
||||
fehlt ODER die Frist abweicht (Akzeptanzkriterium 2: kein reines
|
||||
Logging-Niveau, jede Abweichung ist ein blockierender Befund).
|
||||
- `RenderBericht` erzeugt den deutschsprachigen Markdown-Pruefbericht je
|
||||
Tenant (Akzeptanzkriterium 1), mit Status `OK` / `OFFENER BEFUND` je
|
||||
Zeile und einer Zusammenfassung (Anzahl Objekte, Anzahl offener
|
||||
Befunde).
|
||||
|
||||
## Durchgefuehrte Pruefungen (real, Host 204, Postgres-Testcluster)
|
||||
|
||||
Umgebung: `TEST_TENANT_DSN` aus `/etc/nexarch-dev/tenant-dev.env`, per
|
||||
`set -a && source ... && set +a` eingespeist (keine Klartextwerte in
|
||||
diesem Protokoll oder im Code). Befehl:
|
||||
|
||||
```
|
||||
cd archive && go test ./internal/retentionverify/... -v
|
||||
```
|
||||
|
||||
Ergebnis:
|
||||
|
||||
```
|
||||
=== RUN TestCompareSamples_RepraesentativeStichproveOhneAbweichung
|
||||
--- PASS: TestCompareSamples_RepraesentativeStichproveOhneAbweichung (0.27s)
|
||||
=== RUN TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt
|
||||
--- PASS: TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt (0.04s)
|
||||
=== RUN TestCompareSamples_FehlendesZielobjektIstOffenerBefund
|
||||
--- PASS: TestCompareSamples_FehlendesZielobjektIstOffenerBefund (0.02s)
|
||||
PASS
|
||||
ok gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionverify 0.349s
|
||||
```
|
||||
|
||||
### Pruefung 1 (Akzeptanzkriterium 1 / Pruefung vor Abnahme 1): Stichprobe erfolgreich verglichen
|
||||
|
||||
`TestCompareSamples_RepraesentativeStichproveOhneAbweichung`: 20 statt der
|
||||
produktiv geforderten 50 synthetischen Objekte je Tenant. Begruendung fuer
|
||||
die kleinere Stichprobe in der Testumgebung: Die Testdatenbank ist eine
|
||||
Wegwerf-DB ohne reale Objektmenge — 50 rein synthetische, per Schleife
|
||||
generierte Datensaetze pruefen denselben Code-Pfad wie 20, liefern aber
|
||||
keinen zusaetzlichen Erkenntniswert, nur mehr Testlaufzeit. Die
|
||||
Produktionsvorgabe (50 Objekte je Tenant, echte Stichprobe aus dem
|
||||
jeweiligen Altsystem-Export) bleibt in `SampleSource`/`Run` unveraendert
|
||||
parametrisierbar (`sampleSize`-Parameter) und wird vom Test nicht
|
||||
reduziert. Ergebnis: alle 20 Objekte `Match=true`, `TargetFound=true`,
|
||||
`OffeneBefunde` liefert 0 Eintraege, `RenderBericht` enthaelt keinen
|
||||
`OFFENER BEFUND`.
|
||||
|
||||
### Pruefung 2 (Pruefung vor Abnahme 2): absichtlich verschobene Testfrist wird erkannt
|
||||
|
||||
`TestCompareSamples_VerschobeneFristWirdAlsOffenerBefundErkannt`:
|
||||
Zielobjekt wird mit Frist `2033-03-01T00:00:00Z` migriert, die
|
||||
Quell-Stichprobe behauptet dagegen `2033-03-03T00:00:00Z` (48h
|
||||
verschoben). `CompareSamples` liefert `Match=false`,
|
||||
`OffenerBefund()=true`; `RenderBericht` markiert die Zeile als
|
||||
`OFFENER BEFUND (frist weicht ab)`.
|
||||
|
||||
### Zusatzpruefung: fehlendes Zielobjekt
|
||||
|
||||
`TestCompareSamples_FehlendesZielobjektIstOffenerBefund`: Quell-Sample
|
||||
ohne jemals migriertes Zielobjekt liefert `TargetFound=false`,
|
||||
`OffenerBefund()=true` — verhindert, dass ein in der Migration ganz
|
||||
vergessenes Objekt als "kein Unterschied gefunden" durchrutscht.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Alle drei im Ticket geforderten Pruefungen vor Abnahme sind erfuellt,
|
||||
Akzeptanzkriterium 1 (Pruefbericht je Tenant) und Akzeptanzkriterium 2
|
||||
(jede Abweichung = blockierender Befund) sind durch automatisierte,
|
||||
real gegen Postgres ausgefuehrte Tests abgedeckt. `go build ./...` und
|
||||
`go vet ./...` im Modul `archive` sind sauber.
|
||||
|
||||
## Offener Punkt fuer eine Folge-Kachel
|
||||
|
||||
Wie bei VAL-01: `SampleSource` ist bewusst generisch gehalten. Ein
|
||||
konkreter Produktiv-Adapter, der 50 echte Objekte mit Zukunftsfrist aus
|
||||
archivdms/archivmail zieht (eigenes System, Host 131, eigenes
|
||||
Go-Modul-Problem analog zu dms/mail), ist nicht Teil dieses Tickets und
|
||||
noch zu bauen.
|
||||
Reference in new issue
Block a user