VAL-01: Zeilenzahl-Abgleich je Kerntabelle #274

Merged
patrick merged 1 commits from feature/val-01-zeilenzahl-abgleich-kerntabelle into main 2026-10-14 22:10:13 +02:00
8 changed files with 923 additions and 0 deletions

No files matched your search

+185
View File
@@ -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
}
+143
View File
@@ -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.
+63
View File
@@ -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
}
+252
View File
@@ -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)
}
}
+48
View File
@@ -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
}
+54
View File
@@ -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)
}
+95
View File
@@ -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
}
+83
View File
@@ -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)
}
}