VAL-01: Zeilenzahl-Abgleich je Kerntabelle #274
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user