From 628be89c9a174c18f00bf924dea2f1bd8d1ee1ae Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 20:07:29 +0000 Subject: [PATCH] VAL-01: zeilenzahl-abgleich-kerntabelle Co-Authored-By: Claude Sonnet 5 --- cmd/val01-zeilenzahl-abgleich/main.go | 185 ++++++++++++++++ docs/VAL-01-PRUEFPROTOKOLL.md | 143 ++++++++++++ internal/migrationverify/postgres.go | 63 ++++++ internal/migrationverify/postgres_test.go | 252 ++++++++++++++++++++++ internal/migrationverify/render.go | 48 +++++ internal/migrationverify/report.go | 54 +++++ internal/migrationverify/verify.go | 95 ++++++++ internal/migrationverify/verify_test.go | 83 +++++++ 8 files changed, 923 insertions(+) create mode 100644 cmd/val01-zeilenzahl-abgleich/main.go create mode 100644 docs/VAL-01-PRUEFPROTOKOLL.md create mode 100644 internal/migrationverify/postgres.go create mode 100644 internal/migrationverify/postgres_test.go create mode 100644 internal/migrationverify/render.go create mode 100644 internal/migrationverify/report.go create mode 100644 internal/migrationverify/verify.go create mode 100644 internal/migrationverify/verify_test.go diff --git a/cmd/val01-zeilenzahl-abgleich/main.go b/cmd/val01-zeilenzahl-abgleich/main.go new file mode 100644 index 00000000..b3f006e0 --- /dev/null +++ b/cmd/val01-zeilenzahl-abgleich/main.go @@ -0,0 +1,185 @@ +// Kommandozeilenwerkzeug fuer VAL-01: Zeilenzahl-Abgleich je Kerntabelle +// (Benutzer, Dokumente, Nachrichten) je Tenant. Reines Lese-/Zaehlwerkzeug +// gegen bereits migrierte Tenants (COR-02/DOK-01/MAI-02) — kein neuer +// Migrationsschritt. +// +// Benutzer-Quellzahl kommt aus der freigegebenen user_migration_map +// (Registry-DB, siehe internal/migrationuser). Dokument-/Nachrichten- +// Quellzahlen kommen aus archivdms/archivmail und liegen in diesem +// Repository nicht als Go-Code vor (nur als Source-Interface in +// dms/internal/migrationdocument bzw. mail/internal/messagemigration, +// jeweils eigenes Go-Modul) — sie werden hier ueber eine JSON-Datei +// eingespeist, die das jeweilige Migrationsmodul aus seinem eigenen +// Source.Documents()/Messages() ableitet. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/migrationverify" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +// sourceCountEntry ist eine Zeile der extern angelieferten +// Quellzahlen-Datei (siehe --source-counts). +type sourceCountEntry struct { + TenantSlug string `json:"tenant_slug"` + Documents int64 `json:"documents"` + Messages int64 `json:"messages"` +} + +type explanationEntry struct { + TenantSlug string `json:"tenant_slug"` + Table string `json:"table"` + Count int64 `json:"count"` + Reason string `json:"reason"` +} + +func main() { + registryDSN := flag.String("registry-dsn", "", "DSN der Registry-Datenbank (Pflicht)") + sourceCountsPath := flag.String("source-counts", "", "JSON-Datei mit Dokument-/Nachrichten-Quellzahlen je Tenant (Pflicht)") + explanationsPath := flag.String("explanations", "", "JSON-Datei mit erklaerten Abweichungen (optional)") + outPath := flag.String("out", "", "Zieldatei fuer den Bericht (Standard: stdout)") + flag.Parse() + + if err := run(*registryDSN, *sourceCountsPath, *explanationsPath, *outPath); err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + os.Exit(1) + } +} + +func run(registryDSN, sourceCountsPath, explanationsPath, outPath string) error { + if registryDSN == "" { + return fmt.Errorf("--registry-dsn ist erforderlich") + } + if sourceCountsPath == "" { + return fmt.Errorf("--source-counts ist erforderlich") + } + + ctx := context.Background() + + registryPool, err := pgxpool.New(ctx, registryDSN) + if err != nil { + return fmt.Errorf("registry-db verbinden: %w", err) + } + defer registryPool.Close() + + registry := tenant.NewRegistry(registryPool) + tenants, err := registry.List(ctx) + if err != nil { + return fmt.Errorf("tenants laden: %w", err) + } + + var slugs []string + tenantIDBySlug := map[string]string{} + dsnBySlug := map[string]string{} + for _, t := range tenants { + slugs = append(slugs, t.Slug) + tenantIDBySlug[t.Slug] = t.ID + dsnBySlug[t.Slug] = t.DBDSN + } + + docMailCounts, err := loadSourceCounts(sourceCountsPath, slugs) + if err != nil { + return err + } + + explanations, err := loadExplanations(explanationsPath) + if err != nil { + return err + } + + usersSource := migrationverify.RegistryApprovedUserCountProvider{RegistryPool: registryPool, TenantIDBySlug: tenantIDBySlug} + source := migrationverify.CombineUsers(usersSource, docMailCounts) + + tenantPools := map[string]*pgxpool.Pool{} + defer func() { + for _, p := range tenantPools { + p.Close() + } + }() + target := migrationverify.PostgresTenantCountProvider{Resolver: func(ctx context.Context, slug string) (*pgxpool.Pool, error) { + if pool, ok := tenantPools[slug]; ok { + return pool, nil + } + dsn, ok := dsnBySlug[slug] + if !ok { + return nil, fmt.Errorf("kein dsn fuer tenant %q", slug) + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("tenant %q verbinden: %w", slug, err) + } + tenantPools[slug] = pool + return pool, nil + }} + + reports, err := migrationverify.Run(ctx, slugs, source, target, explanations) + if err != nil { + return err + } + + bericht := migrationverify.RenderBericht(reports) + if outPath != "" { + if err := os.WriteFile(outPath, []byte(bericht), 0o644); err != nil { + return fmt.Errorf("bericht schreiben: %w", err) + } + } else { + fmt.Print(bericht) + } + + if offene := migrationverify.OffeneBefunde(reports); len(offene) > 0 { + fmt.Fprintf(os.Stderr, "%d offene(r) befund(e) gefunden\n", len(offene)) + os.Exit(2) + } + return nil +} + +func loadSourceCounts(path string, slugs []string) (migrationverify.MapCountProvider, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("quellzahlen-datei lesen: %w", err) + } + var entries []sourceCountEntry + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("quellzahlen-datei parsen: %w", err) + } + out := migrationverify.MapCountProvider{} + for _, slug := range slugs { + out[slug] = migrationverify.TenantCounts{TenantSlug: slug} + } + for _, e := range entries { + out[e.TenantSlug] = migrationverify.TenantCounts{TenantSlug: e.TenantSlug, Documents: e.Documents, Messages: e.Messages} + } + return out, nil +} + +func loadExplanations(path string) ([]migrationverify.Explanation, error) { + if path == "" { + return nil, nil + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("erklaerungs-datei lesen: %w", err) + } + var entries []explanationEntry + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("erklaerungs-datei parsen: %w", err) + } + out := make([]migrationverify.Explanation, 0, len(entries)) + for _, e := range entries { + out = append(out, migrationverify.Explanation{ + TenantSlug: e.TenantSlug, + Table: migrationverify.TableName(e.Table), + Count: e.Count, + Reason: e.Reason, + }) + } + return out, nil +} diff --git a/docs/VAL-01-PRUEFPROTOKOLL.md b/docs/VAL-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..f76b5fcd --- /dev/null +++ b/docs/VAL-01-PRUEFPROTOKOLL.md @@ -0,0 +1,143 @@ +# VAL-01 – Pruefprotokoll: Zeilenzahl-Abgleich je Kerntabelle + +## Auftrag + +Automatisierter Abgleich der Zeilenzahl je migrierter Kerntabelle (Benutzer, +Dokumente, Nachrichten) alt vs. neu, je Tenant, abzueglich bewusst nicht +migrierter Kategorien. Reines Lese-/Zaehlwerkzeug (QA-Verifikation), kein +neuer Migrationsschritt. + +## Gelesener Migrationscode (Voraussetzung) + +- `internal/migrationuser/map.go`, `migrate.go` (COR-02): Quellzahl der + Benutzer-Kerntabelle ist die Anzahl freigegebener Zeilen in + `user_migration_map` (`approved_at IS NOT NULL`), je + `nexarch_tenant_id`. Zielzahl ist `COUNT(*) FROM users` in der + physischen Tenant-Datenbank (Modell C). +- `dms/internal/migrationdocument/migrate.go` (DOK-01): Zielzahl der + Dokument-Kerntabelle ist `COUNT(*) FROM documents` in derselben + physischen Tenant-Datenbank. Die Quelle ist das `Source`-Interface + (`Documents(ctx)`), das archivdms per SQL-Export liest — eine + konkrete Produktiv-Implementierung existiert in diesem Repository + noch nicht (nur Testfixtures), da archivdms ein eigenes System auf + Host 131 ist. +- `mail/internal/messagemigration/messagemigration.go` (MAI-02): Es + gibt im Ziel **keine** Postgres-Metadatentabelle fuer Nachrichten — + Metadaten liegen ausschliesslich im Manticore-Suchindex + (`mail/internal/search`). Die Quelle ist ebenfalls ein + `Source`-Interface (`Messages(ctx)`), analog zu DOK-01. + +## Architekturentscheidung + +`dms` und `mail` sind eigene Go-Module (eigene `go.mod`, keine +Abhaengigkeit auf das Root-Modul oder umgekehrt) — ein direkter Import +der `Source`-Interfaces aus `internal/migrationverify` (Root-Modul) ist +daher nicht moeglich, ohne diese Modultrennung aufzubrechen. Das waere +keine "kleinste Loesung" fuer ein reines QA-Werkzeug. + +Entscheidung: `internal/migrationverify` ist ein generischer +Vergleichsmotor (`CountProvider`-Schnittstelle, `TenantCounts`, +`TableReport`, `Run`, `RenderBericht`) ohne Abhaengigkeit auf dms/mail. +Konkrete Zaehl-Adapter: + +- **Benutzer-Quelle**: `RegistryApprovedUserCountProvider` — echte + Postgres-Abfrage gegen `user_migration_map` (Registry-DB). +- **Benutzer-/Dokument-Ziel**: `PostgresTenantCountProvider` — echte + Postgres-Abfragen `COUNT(*) FROM users` / `COUNT(*) FROM documents` in + der physischen Tenant-DB. +- **Dokument-/Nachrichten-Quelle**: wird ueber eine JSON-Datei + eingespeist (`--source-counts`), die das jeweilige Migrationsmodul + (dms bzw. mail, in deren eigenem Go-Modul) aus seinem eigenen + `Source.Documents()`/`Source.Messages()` ableitet und exportiert. + Das haelt die Modultrennung ein und macht die Quelle des Zaehl-Schritts + explizit nachvollziehbar. +- **Nachrichten-Ziel**: in dieser Kachel bewusst nicht an Manticore + angebunden (keine Manticore-Testinstanz in diesem Lauf verfuegbar); + die Schnittstelle (`CountProvider`) ist aber bereits so geschnitten, + dass ein `search.Client`-basierter Zaehl-Adapter (`SELECT COUNT(*) + FROM mail_documents WHERE tenant_slug = ...`, analog zum bestehenden + `EnsureSchema`-Pfad) ohne Aenderung am Kernmotor nachgezogen werden + kann. **Offener Punkt fuer eine Folge-Kachel**, siehe unten. + +Erklaerte Abweichungen (bewusst nicht migrierte Kategorien, z.B. +abgelaufene Freigabelinks, abgeschlossene Workflow-Instanzen) werden als +`Explanation{TenantSlug, Table, Count, Reason}` je Tenant/Tabelle +eingespeist (`--explanations`). Eine Differenz gilt nur dann als +"erklaert", wenn ihre Summe exakt der Differenz entspricht — jede nicht +vollstaendig erklaerte Differenz ist ein offener Befund +(Akzeptanzkriterium 2). + +## Implementierung + +- `internal/migrationverify/report.go` — `TableReport`, `Diff()`, + `OffenerBefund()`. +- `internal/migrationverify/verify.go` — `CountProvider`, + `MapCountProvider`, `CombineUsers`, `Run`. +- `internal/migrationverify/postgres.go` — echte Postgres-Adapter + (`PostgresTenantCountProvider`, `RegistryApprovedUserCountProvider`). +- `internal/migrationverify/render.go` — deutschsprachiger + Markdown-Bericht (`RenderBericht`), `OffeneBefunde`. +- `cmd/val01-zeilenzahl-abgleich/main.go` — CLI-Wrapper: laedt Tenants + aus der Registry, verbindet je Tenant-DB, schreibt den Bericht nach + `--out` oder stdout, Exit-Code 2 bei offenen Befunden. + +## Tests (je Akzeptanzkriterium mindestens einer, real ausgefuehrt) + +Alle Tests real ausgefuehrt gegen den echten Postgres-Testcluster auf +Host 204. Zugangsdaten nie im Klartext ausgegeben — Variablennamen per +`grep -o '^[A-Z_]*=' /etc/nexarch-dev/*.env`, Werte ausschliesslich per +`set -a && source /etc/nexarch-dev/*.env && set +a` im selben +SSH-Befehl eingespeist. + +| Test | Deckt ab | Ergebnis | +|---|---|---| +| `TestRun_ErklaerteUndOffeneAbweichungen` | AC 1 (Bericht je Tenant/Tabelle), AC 2 (erklaert vs. offener Befund) | PASS | +| `TestRun_UnbekannterTenant` | Fehlerpfad: Tenant ohne hinterlegte Zahlen | PASS | +| `TestCombineUsers` | Zusammenfuehren Benutzer-Quelle + Dokument-/Nachrichten-Quelle | PASS | +| `TestOffeneBefunde` | Filterung auf offene Befunde fuer den Bericht | PASS | +| `TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung` (echter Postgres-Cluster, Host 204, `TEST_ADMIN_DSN`) | Pruefung 1 (>= 3 Test-Tenants, Bericht erzeugt) UND Pruefung 2 (absichtlich eingefuegte Zeilenzahl-Abweichung bei `mandant-b`/`documents`, 2 Dokumente im Ziel fehlen, ohne Erklaerung — wird als `OFFENER BEFUND` erkannt; die uebrigen Tenants/Tabellen sind unauffaellig `OK`) | PASS, 4.4–4.7s Laufzeit | + +Lauf (Beispiel): + +``` +go test ./internal/migrationverify/... -v +=== RUN TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung +--- PASS: TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung (4.42s) +=== RUN TestRun_ErklaerteUndOffeneAbweichungen +--- PASS: TestRun_ErklaerteUndOffeneAbweichungen (0.00s) +=== RUN TestRun_UnbekannterTenant +--- PASS: TestRun_UnbekannterTenant (0.00s) +=== RUN TestCombineUsers +--- PASS: TestCombineUsers (0.00s) +=== RUN TestOffeneBefunde +--- PASS: TestOffeneBefunde (0.00s) +PASS +``` + +`go build ./...` und `go vet ./internal/migrationverify/... ./cmd/val01-zeilenzahl-abgleich/...` +fehlerfrei. `gofmt -l` liefert keine Treffer. + +## Datenschutz + +Alle Testfixtures sind synthetisch (fiktive Tenant-Namen `mandant-a/b/c`, +fiktive E-Mail-Adressen unter `example.invalid`, selbst erzeugte +UUIDs). Keine echten archivdms-/archivmail-Daten verwendet oder +angefasst. Keine Zugangsdaten im Code — ausschliesslich ueber +`--registry-dsn`/Umgebungsvariablen zur Laufzeit. + +## Offene Punkte fuer Folge-Kacheln + +1. Echter Nachrichten-Ziel-Zaehl-Adapter gegen Manticore + (`search.Client.CountByTenant` o.ae.), sobald eine Test-Manticore- + Instanz fuer diesen Zweck zur Verfuegung steht. +2. Konkrete, produktive `Source`-Implementierungen fuer + `migrationdocument.Source`/`messagemigration.Source` (SQL-Export + gegen archivdms/archivmail) existieren noch nicht in diesem Repo; + bis dahin bleibt die Dokument-/Nachrichten-Quellzahl fuer dieses + Werkzeug eine extern angelieferte JSON-Datei (`--source-counts`). + +## Branch / Commits / Push + +- Branch: `feature/val-01-zeilenzahl-abgleich-kerntabelle` +- Commit-Nachricht: `VAL-01: zeilenzahl-abgleich-kerntabelle` +- Kein Merge, kein Deploy. diff --git a/internal/migrationverify/postgres.go b/internal/migrationverify/postgres.go new file mode 100644 index 00000000..78fafaf4 --- /dev/null +++ b/internal/migrationverify/postgres.go @@ -0,0 +1,63 @@ +package migrationverify + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TenantPoolResolver liefert den Pool der physischen Tenant-Datenbank +// (Modell C, siehe internal/tenant) zu einem Tenant-Slug. +type TenantPoolResolver func(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) + +// PostgresTenantCountProvider zaehlt users und documents direkt in der +// Tenant-Datenbank, in die COR-02 (internal/migrationuser) bzw. DOK-01 +// (dms/internal/migrationdocument) schreiben. Die Nachrichten-Zieltabelle +// existiert nicht in Postgres (siehe mail/internal/messagemigration, +// Metadaten liegen ausschliesslich im Manticore-Suchindex) — das +// Messages-Feld bleibt hier 0 und muss ueber einen eigenen CountProvider +// eingespeist werden (siehe CombineUsers-Muster). +type PostgresTenantCountProvider struct { + Resolver TenantPoolResolver +} + +func (p PostgresTenantCountProvider) Counts(ctx context.Context, tenantSlug string) (TenantCounts, error) { + pool, err := p.Resolver(ctx, tenantSlug) + if err != nil { + return TenantCounts{}, fmt.Errorf("migrationverify: tenant-pool fuer %q: %w", tenantSlug, err) + } + + var users, documents int64 + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return TenantCounts{}, fmt.Errorf("migrationverify: users zaehlen (%q): %w", tenantSlug, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM documents`).Scan(&documents); err != nil { + return TenantCounts{}, fmt.Errorf("migrationverify: documents zaehlen (%q): %w", tenantSlug, err) + } + return TenantCounts{TenantSlug: tenantSlug, Users: users, Documents: documents}, nil +} + +// RegistryApprovedUserCountProvider zaehlt freigegebene +// user_migration_map-Eintraege je Tenant (siehe +// internal/migrationuser.MapStore.Approved) als Quellzahl der +// Benutzer-Kerntabelle. +type RegistryApprovedUserCountProvider struct { + RegistryPool *pgxpool.Pool + TenantIDBySlug map[string]string +} + +func (p RegistryApprovedUserCountProvider) Counts(ctx context.Context, tenantSlug string) (TenantCounts, error) { + tenantID, ok := p.TenantIDBySlug[tenantSlug] + if !ok { + return TenantCounts{}, fmt.Errorf("migrationverify: kein nexarch_tenant_id fuer slug %q", tenantSlug) + } + + var users int64 + if err := p.RegistryPool.QueryRow(ctx, ` + SELECT COUNT(*) FROM user_migration_map WHERE approved_at IS NOT NULL AND nexarch_tenant_id = $1 + `, tenantID).Scan(&users); err != nil { + return TenantCounts{}, fmt.Errorf("migrationverify: freigegebene user-map-eintraege zaehlen (%q): %w", tenantSlug, err) + } + return TenantCounts{TenantSlug: tenantSlug, Users: users}, nil +} diff --git a/internal/migrationverify/postgres_test.go b/internal/migrationverify/postgres_test.go new file mode 100644 index 00000000..dc196f07 --- /dev/null +++ b/internal/migrationverify/postgres_test.go @@ -0,0 +1,252 @@ +package migrationverify + +import ( + "context" + "crypto/rand" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" +) + +// Integrationstest gegen eine echte Postgres-Instanz (Host 204, +// TEST_ADMIN_DSN) mit synthetischen Tenant-Fixtures — keine echten +// archivdms-/archivmail-Daten, siehe VAL-01-Pruefprotokoll. + +func setupRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS tenant_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_tenant_id BIGINT, + archivdms_tenant_id BIGINT, + nexarch_tenant_id UUID NOT NULL UNIQUE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT tenant_migration_map_source_present + CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL) + ); + CREATE TABLE IF NOT EXISTS user_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_user_id BIGINT, + archivdms_user_id BIGINT, + nexarch_tenant_id UUID NOT NULL REFERENCES tenant_migration_map(nexarch_tenant_id), + nexarch_user_id UUID NOT NULL UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("registry-schema: %v", err) + } +} + +const tenantSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE +); +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL +);` + +// createSynthTenantDB legt eine eigene, echte Tenant-Datenbank mit +// users/documents-Tabellen an (Stellvertreter fuer COR-01/DOK-01-Ziel). +func createSynthTenantDB(t *testing.T, ctx context.Context, adminDSN string) (dsn string) { + t.Helper() + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + defer adminPool.Close() + + dbName := "val01_" + testdb.Suffix() + if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + t.Fatalf("tenant-db anlegen: %v", err) + } + t.Cleanup(func() { + cleanupPool, err := pgxpool.New(context.Background(), adminDSN) + if err == nil { + _, _ = cleanupPool.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + cleanupPool.Close() + } + }) + + dsn = strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1) + tenantPool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("tenant-db verbinden: %v", err) + } + defer tenantPool.Close() + if _, err := tenantPool.Exec(ctx, tenantSchema); err != nil { + t.Fatalf("tenant-schema anwenden: %v", err) + } + return dsn +} + +func newUUIDv4() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung deckt die +// beiden Pruefungen vor Abnahme aus VAL-01 in einem Lauf ab: Bericht fuer +// mindestens 3 Test-Tenants, und eine absichtlich eingefuegte +// Zeilenzahl-Abweichung (mandant-b bekommt zwei documents-Zeilen weniger +// im Ziel als in der Quelle, ohne Erklaerung) wird erkannt. +func TestZeilenzahlAbgleich_DreiTenantsMitEingefuegterAbweichung(t *testing.T) { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + registryPool := testdb.Pool(t, adminDSN) + setupRegistrySchema(t, ctx, registryPool) + + type fixture struct { + slug string + tenantID string + pool *pgxpool.Pool + approvedUsers int + documentsSource int + documentsInTarget int + } + + names := []string{"mandant-a", "mandant-b", "mandant-c"} + var fixtures []fixture + tenantIDBySlug := map[string]string{} + + for i, name := range names { + dsn := createSynthTenantDB(t, ctx, adminDSN) + tenantPool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("tenant pool: %v", err) + } + t.Cleanup(tenantPool.Close) + + tenantID := newUUIDv4() + slug := testdb.Slug(name) + if _, err := registryPool.Exec(ctx, ` + INSERT INTO tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, approved_by, approved_at) + VALUES ($1, NULL, $2, $3, $4, 'qa-tester', now()) + `, int64(1000+i), tenantID, slug, name); err != nil { + t.Fatalf("tenant-map anlegen: %v", err) + } + tenantIDBySlug[slug] = tenantID + + approvedUsers := 4 + for u := 0; u < approvedUsers; u++ { + email := fmt.Sprintf("synth.%d.%d.%s@example.invalid", i, u, testdb.Suffix()) + if _, err := registryPool.Exec(ctx, ` + INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, email, name, password_hash, approved_by, approved_at) + VALUES ($1, NULL, $2, $3, $4, 'Synth', 'x', 'qa-tester', now()) + `, int64(2000+i*100+u), tenantID, newUUIDv4(), email); err != nil { + t.Fatalf("user-map-eintrag anlegen: %v", err) + } + if _, err := tenantPool.Exec(ctx, `INSERT INTO users (email) VALUES ($1)`, email); err != nil { + t.Fatalf("tenant-user anlegen: %v", err) + } + } + + documentsSource := 10 + documentsInTarget := documentsSource + if name == "mandant-b" { + // Absichtlich eingefuegte Testabweichung (Pruefung 2): zwei + // Dokumente fehlen im Ziel, ohne Erklaerung. + documentsInTarget = documentsSource - 2 + } + for d := 0; d < documentsInTarget; d++ { + if _, err := tenantPool.Exec(ctx, `INSERT INTO documents (title) VALUES ($1)`, fmt.Sprintf("Dokument %d", d)); err != nil { + t.Fatalf("tenant-dokument anlegen: %v", err) + } + } + + fixtures = append(fixtures, fixture{ + slug: slug, tenantID: tenantID, pool: tenantPool, + approvedUsers: approvedUsers, documentsSource: documentsSource, documentsInTarget: documentsInTarget, + }) + } + + pools := map[string]*pgxpool.Pool{} + var slugs []string + documentSourceCounts := MapCountProvider{} + for _, f := range fixtures { + pools[f.slug] = f.pool + slugs = append(slugs, f.slug) + documentSourceCounts[f.slug] = TenantCounts{TenantSlug: f.slug, Documents: int64(f.documentsSource)} + } + + usersSource := RegistryApprovedUserCountProvider{RegistryPool: registryPool, TenantIDBySlug: tenantIDBySlug} + source := CombineUsers(usersSource, documentSourceCounts) + + target := PostgresTenantCountProvider{Resolver: func(ctx context.Context, slug string) (*pgxpool.Pool, error) { + pool, ok := pools[slug] + if !ok { + return nil, fmt.Errorf("kein pool fuer %q", slug) + } + return pool, nil + }} + + reports, err := Run(ctx, slugs, source, target, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(reports) != len(fixtures)*3 { + t.Fatalf("erwarte %d Reports, habe %d", len(fixtures)*3, len(reports)) + } + + byKey := map[string]TableReport{} + for _, r := range reports { + byKey[r.TenantSlug+"/"+string(r.Table)] = r + } + + for _, f := range fixtures { + usersReport := byKey[f.slug+"/users"] + if usersReport.SourceCount != int64(f.approvedUsers) || usersReport.TargetCount != int64(f.approvedUsers) { + t.Fatalf("%s/users: erwarte quelle=ziel=%d, habe %+v", f.slug, f.approvedUsers, usersReport) + } + if usersReport.OffenerBefund() { + t.Fatalf("%s/users sollte ohne Abweichung OK sein", f.slug) + } + + docReport := byKey[f.slug+"/documents"] + if docReport.SourceCount != int64(f.documentsSource) || docReport.TargetCount != int64(f.documentsInTarget) { + t.Fatalf("%s/documents: erwarte quelle=%d ziel=%d, habe %+v", f.slug, f.documentsSource, f.documentsInTarget, docReport) + } + + if f.slug == fixtures[1].slug { + if !docReport.OffenerBefund() { + t.Fatalf("absichtlich eingefuegte Testabweichung bei %s/documents wurde nicht erkannt", f.slug) + } + } else if docReport.OffenerBefund() { + t.Fatalf("%s/documents sollte ohne Abweichung OK sein, habe %+v", f.slug, docReport) + } + } + + bericht := RenderBericht(reports) + if !strings.Contains(bericht, "OFFENER BEFUND") { + t.Fatal("bericht sollte die eingefuegte Abweichung als OFFENER BEFUND ausweisen") + } + if got := len(OffeneBefunde(reports)); got != 1 { + t.Fatalf("erwarte genau einen offenen Befund (mandant-b/documents), habe %d", got) + } +} diff --git a/internal/migrationverify/render.go b/internal/migrationverify/render.go new file mode 100644 index 00000000..055b7381 --- /dev/null +++ b/internal/migrationverify/render.go @@ -0,0 +1,48 @@ +package migrationverify + +import ( + "fmt" + "sort" + "strings" +) + +// RenderBericht erzeugt den deutschsprachigen Zeilenzahl-Abgleich-Bericht +// als Markdown-Tabelle, sortiert nach Tenant und Tabelle. +func RenderBericht(reports []TableReport) string { + sorted := append([]TableReport(nil), reports...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].TenantSlug != sorted[j].TenantSlug { + return sorted[i].TenantSlug < sorted[j].TenantSlug + } + return sorted[i].Table < sorted[j].Table + }) + + var b strings.Builder + b.WriteString("# Zeilenzahl-Abgleich je Kerntabelle\n\n") + b.WriteString("| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung |\n") + b.WriteString("|---|---|---|---|---|---|---|---|\n") + for _, r := range sorted { + status := "OK" + if r.OffenerBefund() { + status = "OFFENER BEFUND" + } + reason := strings.Join(r.ExplainedReasons, "; ") + if reason == "" { + reason = "-" + } + fmt.Fprintf(&b, "| %s | %s | %d | %d | %d | %d | %s | %s |\n", + r.TenantSlug, r.Table, r.SourceCount, r.TargetCount, r.Diff(), r.ExplainedCount, status, reason) + } + return b.String() +} + +// OffeneBefunde filtert alle Reports mit ungeklaerter Abweichung. +func OffeneBefunde(reports []TableReport) []TableReport { + var out []TableReport + for _, r := range reports { + if r.OffenerBefund() { + out = append(out, r) + } + } + return out +} diff --git a/internal/migrationverify/report.go b/internal/migrationverify/report.go new file mode 100644 index 00000000..5d36e53b --- /dev/null +++ b/internal/migrationverify/report.go @@ -0,0 +1,54 @@ +// Package migrationverify implementiert VAL-01: den Zeilenzahl-Abgleich je +// Kerntabelle (Benutzer, Dokumente, Nachrichten) zwischen Quelle und +// migriertem Ziel, je Tenant. Reines Lese-/Zaehlwerkzeug, kein +// Migrationsschritt. +package migrationverify + +import "fmt" + +type TableName string + +const ( + TableUsers TableName = "users" + TableDocuments TableName = "documents" + TableMessages TableName = "messages" +) + +// Explanation deckt eine bewusst nicht migrierte Kategorie ab (z.B. +// abgelaufene Freigabelinks, abgeschlossene Workflow-Instanzen). +type Explanation struct { + TenantSlug string + Table TableName + Count int64 + Reason string +} + +// TableReport ist der Zeilenzahl-Abgleich einer Kerntabelle fuer genau +// einen Tenant (Akzeptanzkriterium 1). +type TableReport struct { + TenantSlug string + Table TableName + SourceCount int64 + TargetCount int64 + ExplainedCount int64 + ExplainedReasons []string +} + +func (r TableReport) Diff() int64 { + return r.SourceCount - r.TargetCount +} + +// OffenerBefund ist true, wenn die Differenz nicht vollstaendig durch +// bewusst nicht migrierte Kategorien erklaert ist (Akzeptanzkriterium 2). +func (r TableReport) OffenerBefund() bool { + return r.Diff() != r.ExplainedCount +} + +func (r TableReport) String() string { + status := "OK" + if r.OffenerBefund() { + status = "OFFENER BEFUND" + } + return fmt.Sprintf("%s/%s: quelle=%d ziel=%d differenz=%d erklaert=%d status=%s", + r.TenantSlug, r.Table, r.SourceCount, r.TargetCount, r.Diff(), r.ExplainedCount, status) +} diff --git a/internal/migrationverify/verify.go b/internal/migrationverify/verify.go new file mode 100644 index 00000000..d59f70fc --- /dev/null +++ b/internal/migrationverify/verify.go @@ -0,0 +1,95 @@ +package migrationverify + +import ( + "context" + "fmt" +) + +// TenantCounts sind die Zeilenzahlen aller Kerntabellen fuer einen Tenant. +type TenantCounts struct { + TenantSlug string + Users int64 + Documents int64 + Messages int64 +} + +// CountProvider liefert die Zeilenzahlen einer Seite (Quelle oder Ziel) +// fuer einen Tenant. +type CountProvider interface { + Counts(ctx context.Context, tenantSlug string) (TenantCounts, error) +} + +// MapCountProvider ist ein fest hinterlegter CountProvider, z.B. aus einer +// synthetischen Testfixture oder einem vorab erzeugten Export. +type MapCountProvider map[string]TenantCounts + +func (m MapCountProvider) Counts(ctx context.Context, tenantSlug string) (TenantCounts, error) { + tc, ok := m[tenantSlug] + if !ok { + return TenantCounts{}, fmt.Errorf("migrationverify: keine zahlen fuer tenant %q hinterlegt", tenantSlug) + } + return tc, nil +} + +// combinedUsersProvider ueberlagert das Users-Feld von usersSource ueber +// die uebrigen Tabellen aus restSource — gebraucht, weil die Benutzer- +// Quellzahl (user_migration_map) und die Dokument-/Nachrichten-Quellzahl +// aus unterschiedlichen Herkunftssystemen kommen. +type combinedUsersProvider struct { + users CountProvider + rest CountProvider +} + +// CombineUsers kombiniert einen Users-CountProvider mit einem Provider fuer +// Documents/Messages zu einer gemeinsamen Quelle. +func CombineUsers(users, rest CountProvider) CountProvider { + return combinedUsersProvider{users: users, rest: rest} +} + +func (c combinedUsersProvider) Counts(ctx context.Context, tenantSlug string) (TenantCounts, error) { + u, err := c.users.Counts(ctx, tenantSlug) + if err != nil { + return TenantCounts{}, err + } + r, err := c.rest.Counts(ctx, tenantSlug) + if err != nil { + return TenantCounts{}, err + } + r.TenantSlug = tenantSlug + r.Users = u.Users + return r, nil +} + +// Run vergleicht je Tenant und Kerntabelle Quelle gegen Ziel +// (Akzeptanzkriterium 1) und markiert nicht durch explanations gedeckte +// Differenzen als offenen Befund (Akzeptanzkriterium 2). +func Run(ctx context.Context, tenantSlugs []string, source, target CountProvider, explanations []Explanation) ([]TableReport, error) { + var reports []TableReport + for _, slug := range tenantSlugs { + sc, err := source.Counts(ctx, slug) + if err != nil { + return nil, fmt.Errorf("migrationverify: quellzahlen fuer tenant %q: %w", slug, err) + } + tc, err := target.Counts(ctx, slug) + if err != nil { + return nil, fmt.Errorf("migrationverify: zielzahlen fuer tenant %q: %w", slug, err) + } + reports = append(reports, + buildReport(slug, TableUsers, sc.Users, tc.Users, explanations), + buildReport(slug, TableDocuments, sc.Documents, tc.Documents, explanations), + buildReport(slug, TableMessages, sc.Messages, tc.Messages, explanations), + ) + } + return reports, nil +} + +func buildReport(slug string, table TableName, source, target int64, explanations []Explanation) TableReport { + r := TableReport{TenantSlug: slug, Table: table, SourceCount: source, TargetCount: target} + for _, e := range explanations { + if e.TenantSlug == slug && e.Table == table { + r.ExplainedCount += e.Count + r.ExplainedReasons = append(r.ExplainedReasons, e.Reason) + } + } + return r +} diff --git a/internal/migrationverify/verify_test.go b/internal/migrationverify/verify_test.go new file mode 100644 index 00000000..6b2abdbd --- /dev/null +++ b/internal/migrationverify/verify_test.go @@ -0,0 +1,83 @@ +package migrationverify + +import ( + "context" + "testing" +) + +func TestRun_ErklaerteUndOffeneAbweichungen(t *testing.T) { + ctx := context.Background() + + source := MapCountProvider{ + "mandant-a": {TenantSlug: "mandant-a", Users: 10, Documents: 50, Messages: 100}, + "mandant-b": {TenantSlug: "mandant-b", Users: 5, Documents: 20, Messages: 40}, + } + // mandant-b/messages ist eine absichtlich eingefuegte, unerklaerte + // Testabweichung (Pruefung 2 aus VAL-01). + target := MapCountProvider{ + "mandant-a": {TenantSlug: "mandant-a", Users: 10, Documents: 48, Messages: 100}, + "mandant-b": {TenantSlug: "mandant-b", Users: 5, Documents: 20, Messages: 37}, + } + explanations := []Explanation{ + {TenantSlug: "mandant-a", Table: TableDocuments, Count: 2, Reason: "abgelaufene Freigabelinks (bewusst nicht migriert)"}, + } + + reports, err := Run(ctx, []string{"mandant-a", "mandant-b"}, source, target, explanations) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(reports) != 6 { + t.Fatalf("erwarte 6 Reports (2 Tenants x 3 Tabellen), habe %d", len(reports)) + } + + byKey := map[string]TableReport{} + for _, r := range reports { + byKey[r.TenantSlug+"/"+string(r.Table)] = r + } + + if r := byKey["mandant-a/documents"]; r.OffenerBefund() { + t.Fatalf("mandant-a/documents sollte durch Erklaerung gedeckt sein, diff=%d erklaert=%d", r.Diff(), r.ExplainedCount) + } + if r := byKey["mandant-a/users"]; r.OffenerBefund() { + t.Fatalf("mandant-a/users sollte ohne Abweichung OK sein, diff=%d", r.Diff()) + } + if r := byKey["mandant-b/messages"]; !r.OffenerBefund() { + t.Fatal("absichtlich eingefuegte Testabweichung mandant-b/messages wurde nicht erkannt") + } + if r := byKey["mandant-b/users"]; r.OffenerBefund() { + t.Fatalf("mandant-b/users sollte ohne Abweichung OK sein, diff=%d", r.Diff()) + } +} + +func TestRun_UnbekannterTenant(t *testing.T) { + ctx := context.Background() + if _, err := Run(ctx, []string{"unbekannt"}, MapCountProvider{}, MapCountProvider{}, nil); err == nil { + t.Fatal("erwarte Fehler fuer tenant ohne hinterlegte Zahlen") + } +} + +func TestCombineUsers(t *testing.T) { + ctx := context.Background() + users := MapCountProvider{"mandant-a": {Users: 7}} + rest := MapCountProvider{"mandant-a": {Documents: 3, Messages: 9}} + + combined := CombineUsers(users, rest) + tc, err := combined.Counts(ctx, "mandant-a") + if err != nil { + t.Fatalf("Counts: %v", err) + } + if tc.Users != 7 || tc.Documents != 3 || tc.Messages != 9 { + t.Fatalf("erwarte users=7 documents=3 messages=9, habe %+v", tc) + } +} + +func TestOffeneBefunde(t *testing.T) { + reports := []TableReport{ + {TenantSlug: "a", Table: TableUsers, SourceCount: 5, TargetCount: 5}, + {TenantSlug: "a", Table: TableDocuments, SourceCount: 5, TargetCount: 3}, + } + offen := OffeneBefunde(reports) + if len(offen) != 1 || offen[0].Table != TableDocuments { + t.Fatalf("erwarte genau einen offenen Befund (documents), habe %+v", offen) + } +} -- 2.47.3