MAI-04: reconciliation-reports-archivierung
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Migriert historische Reconciliation-Reports aus archivmail als reinen Nachweis nach Archive BAK-05 (neue Tabelle reconciliation_report_archive statt wormstore, da chattr +i auf dem ZFS-Testhost nicht durchsetzbar ist). Tenant-Zuordnung strukturell ueber die Zieldatenbankverbindung (Modell C). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bd7e2a953
commit
0696906daf
5 files changed
+448
No files matched your search
@@ -0,0 +1,123 @@
|
||||
// Package reconcilereportmigration implementiert MAI-04: Übernahme
|
||||
// historischer Reconciliation-Reports aus archivmail als Nachweis in
|
||||
// Archive BAK-05 (archive/internal/reconcile) — kein aktiver
|
||||
// Datenbestand, rein historischer Beleg.
|
||||
//
|
||||
// Tenant-Zuordnung ist strukturell über die Zieldatenbankverbindung
|
||||
// garantiert (Modell C, Core TEN-01: eine physisch isolierte Datenbank
|
||||
// je Mandant, wie bereits in archive/internal/reconcile.ListDBStorageKeys)
|
||||
// — kein tenant_slug-Feld in reconciliation_report_archive nötig, jede
|
||||
// Verbindung spricht nur mit genau einer Mandanten-Datenbank.
|
||||
package reconcilereportmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SourceReport ist eine Zeile aus archivmails historischer
|
||||
// Reconciliation-Report-Tabelle (SQL-Export). IndexedAt wird absichtlich
|
||||
// nicht übernommen (Produkt-DNA: kein Übernehmen von indexed_at-
|
||||
// Statusfeldern).
|
||||
type SourceReport struct {
|
||||
SourceReportID string
|
||||
GeneratedAt time.Time
|
||||
ReportJSON []byte
|
||||
IndexedAt *int64
|
||||
}
|
||||
|
||||
// Source kapselt den lesenden SQL-Export-Zugriff auf archivmails
|
||||
// historische Reconciliation-Reports.
|
||||
type Source interface {
|
||||
Reports(ctx context.Context) ([]SourceReport, error)
|
||||
}
|
||||
|
||||
// Migrator überträgt historische Reports einer Quelle in genau eine
|
||||
// Ziel-Mandantendatenbank.
|
||||
type Migrator struct {
|
||||
source Source
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewMigrator(source Source, pool *pgxpool.Pool) *Migrator {
|
||||
return &Migrator{source: source, pool: pool}
|
||||
}
|
||||
|
||||
// Result fasst den Ausgang eines Migrationslaufs zusammen.
|
||||
type Result struct {
|
||||
ReportsMigrated int
|
||||
ReportsSkipped int
|
||||
}
|
||||
|
||||
// ErrNotFound meldet, dass unter der angefragten Quell-ID kein
|
||||
// archivierter Report gefunden wurde.
|
||||
var ErrNotFound = errors.New("reconcilereportmigration: report nicht gefunden")
|
||||
|
||||
// MigrateAll überträgt alle Reports (Akzeptanzkriterium 1). Ein bereits
|
||||
// migrierter Report (gleiche source_report_id bereits vorhanden) wird
|
||||
// übersprungen, statt erneut geschrieben zu werden (Idempotenz).
|
||||
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
reports, err := m.source.Reports(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reconcilereportmigration: reports aus quelle lesen: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range reports {
|
||||
migrated, err := m.migrateReport(ctx, r)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if migrated {
|
||||
res.ReportsMigrated++
|
||||
} else {
|
||||
res.ReportsSkipped++
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateReport(ctx context.Context, r SourceReport) (migrated bool, err error) {
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO reconciliation_report_archive (source_report_id, generated_at, report_json)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (source_report_id) DO NOTHING
|
||||
`, r.SourceReportID, r.GeneratedAt, r.ReportJSON)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reconcilereportmigration: report %q speichern: %w", r.SourceReportID, err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// ArchivedReport ist ein bereits migrierter, auffindbarer Report
|
||||
// (Akzeptanzkriterium 1).
|
||||
type ArchivedReport struct {
|
||||
SourceReportID string
|
||||
GeneratedAt time.Time
|
||||
ReportJSON []byte
|
||||
MigratedAt time.Time
|
||||
}
|
||||
|
||||
// FindBySourceID macht einen migrierten Report in der Zieldatenbank
|
||||
// auffindbar. Die Tenant-Zuordnung ergibt sich daraus, mit welcher
|
||||
// Mandantendatenbank pool verbunden ist.
|
||||
func FindBySourceID(ctx context.Context, pool *pgxpool.Pool, sourceReportID string) (ArchivedReport, error) {
|
||||
var ar ArchivedReport
|
||||
ar.SourceReportID = sourceReportID
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT generated_at, report_json, migrated_at
|
||||
FROM reconciliation_report_archive WHERE source_report_id = $1
|
||||
`, sourceReportID).Scan(&ar.GeneratedAt, &ar.ReportJSON, &ar.MigratedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ArchivedReport{}, ErrNotFound
|
||||
}
|
||||
return ArchivedReport{}, fmt.Errorf("reconcilereportmigration: report %q lesen: %w", sourceReportID, err)
|
||||
}
|
||||
return ar, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package reconcilereportmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// fakeSource ist eine rein synthetische archivmail-Quelle — keine echten
|
||||
// Kunden-/Mandantendaten (DSGVO-Hartregel).
|
||||
type fakeSource struct {
|
||||
reports []SourceReport
|
||||
}
|
||||
|
||||
func (f *fakeSource) Reports(_ context.Context) ([]SourceReport, error) {
|
||||
return f.reports, nil
|
||||
}
|
||||
|
||||
func setupPool(t *testing.T, envVar string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(envVar)
|
||||
if dsn == "" {
|
||||
t.Skipf("%s nicht gesetzt, Integrationstest uebersprungen", envVar)
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS reconciliation_report_archive (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_report_id TEXT NOT NULL UNIQUE,
|
||||
generated_at TIMESTAMPTZ NOT NULL,
|
||||
report_json JSONB NOT NULL,
|
||||
migrated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM reconciliation_report_archive WHERE source_report_id LIKE 'mai04test_%'`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func synthReports() []SourceReport {
|
||||
oldStatus := int64(1700000000)
|
||||
return []SourceReport{
|
||||
{
|
||||
SourceReportID: "mai04test_r1",
|
||||
GeneratedAt: time.Date(2021, 3, 1, 5, 0, 0, 0, time.UTC),
|
||||
ReportJSON: []byte(`{"missing_in_storage":[],"orphaned_in_storage":[],"existing_in_storage":[{"storage_key":"synth-key-1"}]}`),
|
||||
IndexedAt: &oldStatus,
|
||||
},
|
||||
{
|
||||
SourceReportID: "mai04test_r2",
|
||||
GeneratedAt: time.Date(2021, 4, 1, 5, 0, 0, 0, time.UTC),
|
||||
ReportJSON: []byte(`{"missing_in_storage":[{"storage_key":"synth-key-2"}],"orphaned_in_storage":[],"existing_in_storage":[]}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_ReportsAreFindableWithOriginalContent ist
|
||||
// Akzeptanzkriterium 1 und Prüfung 1 (Stichprobenvergleich vor/nach
|
||||
// Migration): jeder historische Report ist nach Migration auffindbar,
|
||||
// mit unverändertem Inhalt.
|
||||
func TestMigrateAll_ReportsAreFindableWithOriginalContent(t *testing.T) {
|
||||
pool := setupPool(t, "TEST_TENANT_DSN")
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{reports: synthReports()}
|
||||
m := NewMigrator(src, pool)
|
||||
|
||||
res, err := m.MigrateAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("migrateall: %v", err)
|
||||
}
|
||||
if res.ReportsMigrated != 2 || res.ReportsSkipped != 0 {
|
||||
t.Fatalf("erwartet 2 migriert/0 übersprungen, habe %+v", res)
|
||||
}
|
||||
|
||||
for _, want := range synthReports() {
|
||||
got, err := FindBySourceID(ctx, pool, want.SourceReportID)
|
||||
if err != nil {
|
||||
t.Fatalf("report %q nicht auffindbar: %v", want.SourceReportID, err)
|
||||
}
|
||||
var gotDecoded, wantDecoded any
|
||||
if err := json.Unmarshal(got.ReportJSON, &gotDecoded); err != nil {
|
||||
t.Fatalf("report %q: migrierten inhalt dekodieren: %v", want.SourceReportID, err)
|
||||
}
|
||||
if err := json.Unmarshal(want.ReportJSON, &wantDecoded); err != nil {
|
||||
t.Fatalf("report %q: erwarteten inhalt dekodieren: %v", want.SourceReportID, err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotDecoded, wantDecoded) {
|
||||
t.Fatalf("report %q inhalt weicht ab: habe %s, erwartet %s", want.SourceReportID, got.ReportJSON, want.ReportJSON)
|
||||
}
|
||||
if !got.GeneratedAt.Equal(want.GeneratedAt) {
|
||||
t.Fatalf("report %q generated_at weicht ab: habe %v, erwartet %v", want.SourceReportID, got.GeneratedAt, want.GeneratedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_SecondRunSkipsAlreadyMigratedReports beweist Idempotenz:
|
||||
// ein erneuter Lauf gegen dieselbe Quelle überschreibt nichts.
|
||||
func TestMigrateAll_SecondRunSkipsAlreadyMigratedReports(t *testing.T) {
|
||||
pool := setupPool(t, "TEST_TENANT_DSN")
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{reports: synthReports()}
|
||||
m := NewMigrator(src, pool)
|
||||
|
||||
if _, err := m.MigrateAll(ctx); err != nil {
|
||||
t.Fatalf("erster lauf: %v", err)
|
||||
}
|
||||
|
||||
res, err := m.MigrateAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter lauf: %v", err)
|
||||
}
|
||||
if res.ReportsMigrated != 0 || res.ReportsSkipped != 2 {
|
||||
t.Fatalf("erwartet 0 migriert/2 übersprungen beim zweiten lauf, habe %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindBySourceID_UnknownIDReturnsErrNotFound.
|
||||
func TestFindBySourceID_UnknownIDReturnsErrNotFound(t *testing.T) {
|
||||
pool := setupPool(t, "TEST_TENANT_DSN")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := FindBySourceID(ctx, pool, "mai04test_unknown")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("erwartet ErrNotFound, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase ist
|
||||
// Akzeptanzkriterium 1 (korrekte Tenant-Zuordnung): ein in Tenant-A
|
||||
// migrierter Report ist über die Datenbankverbindung von Tenant B NICHT
|
||||
// auffindbar — die Trennung ist strukturell (Modell C, eine physisch
|
||||
// isolierte Datenbank je Mandant), keine Anwendungslogik könnte sie
|
||||
// versehentlich umgehen.
|
||||
func TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase(t *testing.T) {
|
||||
poolA := setupPool(t, "TEST_TENANT_DSN")
|
||||
poolB := setupPool(t, "TEST_TENANT_DSN_B")
|
||||
ctx := context.Background()
|
||||
|
||||
src := &fakeSource{reports: synthReports()}
|
||||
m := NewMigrator(src, poolA)
|
||||
if _, err := m.MigrateAll(ctx); err != nil {
|
||||
t.Fatalf("migrateall gegen tenant a: %v", err)
|
||||
}
|
||||
|
||||
if _, err := FindBySourceID(ctx, poolA, "mai04test_r1"); err != nil {
|
||||
t.Fatalf("report in tenant a nicht auffindbar: %v", err)
|
||||
}
|
||||
if _, err := FindBySourceID(ctx, poolB, "mai04test_r1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("erwartet ErrNotFound in tenant b, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceReport_IndexedAtIsNeverPersisted beweist Produkt-DNA: kein
|
||||
// Übernehmen von indexed_at-Statusfeldern — die Spalte existiert in der
|
||||
// Zieltabelle bewusst nicht.
|
||||
func TestSourceReport_IndexedAtIsNeverPersisted(t *testing.T) {
|
||||
pool := setupPool(t, "TEST_TENANT_DSN")
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{reports: synthReports()}
|
||||
m := NewMigrator(src, pool)
|
||||
|
||||
if _, err := m.MigrateAll(ctx); err != nil {
|
||||
t.Fatalf("migrateall: %v", err)
|
||||
}
|
||||
|
||||
var columnExists bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'reconciliation_report_archive' AND column_name = 'indexed_at'
|
||||
)
|
||||
`).Scan(&columnExists)
|
||||
if err != nil {
|
||||
t.Fatalf("spaltenprüfung: %v", err)
|
||||
}
|
||||
if columnExists {
|
||||
t.Fatal("reconciliation_report_archive darf keine indexed_at-spalte haben")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE reconciliation_report_archive;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE reconciliation_report_archive (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_report_id TEXT NOT NULL UNIQUE,
|
||||
generated_at TIMESTAMPTZ NOT NULL,
|
||||
report_json JSONB NOT NULL,
|
||||
migrated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
# MAI-04 – Prüfprotokoll: Reconciliation-Reports-Archivierung
|
||||
|
||||
## Grundsatzentscheidung: Zieltabelle statt Objekt-Storage
|
||||
|
||||
Archive BAK-05 (`archive/internal/reconcile`) erzeugt bislang nur einen
|
||||
JSON-Report auf stdout, ohne Persistenz (siehe `BAK-05-PRUEFPROTOKOLL.md`).
|
||||
Für MAI-04 ("rein historischer Beleg", keine Live-Erzeugung) genügt
|
||||
daher eine eigene, kleine Archivtabelle statt Erweiterung der
|
||||
reinen Vergleichsfunktion `Reconcile`.
|
||||
|
||||
Gegen `archive/internal/wormstore` (ebenfalls als mögliches Ziel
|
||||
geprüft, "Nachweis" liegt dort nahe) entschieden: wormstore setzt
|
||||
`chattr +i` auf dem Basisverzeichnis voraus (Storage-Object-Lock) — auf
|
||||
Host 204 läuft das Dateisystem auf ZFS, `chattr +i` schlägt dort mit
|
||||
"Operation not permitted" fehl (verifiziert). Ein Test gegen wormstore
|
||||
wäre auf diesem Host strukturell übersprungen worden (siehe
|
||||
`wormLockDir`-Skip-Logik), nicht real ausgeführt — verstößt gegen die
|
||||
Vorgabe "alle Prüfungen real durchführen". Die neue Tabelle
|
||||
`reconciliation_report_archive` (reine SQL-Persistenz, analog zu
|
||||
`archive/internal/complianceapi`s `compliance_reports`) ist auf jedem
|
||||
Postgres real testbar.
|
||||
|
||||
## Tenant-Zuordnung strukturell über die Zieldatenbank
|
||||
|
||||
Wie `archive/internal/reconcile.ListDBStorageKeys` (BAK-05) läuft die
|
||||
Migration gegen genau EINE Mandantendatenbank (Modell C, Core TEN-01:
|
||||
physisch isolierte Datenbank je Mandant) — kein `tenant_slug`-Feld in
|
||||
`reconciliation_report_archive`, die Trennung ist durch die
|
||||
Verbindungs-DSN selbst garantiert, nicht durch Anwendungslogik.
|
||||
`TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase` beweist das
|
||||
real gegen zwei unterschiedliche Testdatenbanken
|
||||
(`TEST_TENANT_DSN`/`TEST_TENANT_DSN_B`).
|
||||
|
||||
## Produkt-DNA
|
||||
|
||||
- `SourceReport.IndexedAt` wird von `migrateReport` nirgendwo gelesen
|
||||
(Feld existiert nur zur Dokumentation der Quellstruktur, wie
|
||||
`SourceMessage.IndexedAt` in MAI-02). `reconciliation_report_archive`
|
||||
hat bewusst keine `indexed_at`-Spalte —
|
||||
`TestSourceReport_IndexedAtIsNeverPersisted` prüft das real gegen
|
||||
`information_schema.columns`.
|
||||
- Zugangsdaten (`TEST_TENANT_DSN`, `TEST_TENANT_DSN_B`) ausschließlich
|
||||
über Umgebungsvariablen aus `/etc/nexarch-dev/tenant-dev.env`, nie im
|
||||
Code oder in Prompts im Klartext.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/migrations/0018_reconciliation_report_archive.up.sql` /
|
||||
`.down.sql` — neue Tabelle, UNIQUE auf `source_report_id` für
|
||||
Idempotenz (`ON CONFLICT ... DO NOTHING`).
|
||||
- `archive/internal/reconcilereportmigration` (gleiches Muster wie
|
||||
MAI-02 `mail/internal/messagemigration`):
|
||||
- `Source` — Schnittstelle für den SQL-Export-Zugriff auf archivmails
|
||||
historische Reconciliation-Reports, Tests verwenden ausschließlich
|
||||
synthetische Fixtures.
|
||||
- `Migrator.MigrateAll` — überträgt alle Reports, überspringt bereits
|
||||
migrierte (gleiche `source_report_id`).
|
||||
- `FindBySourceID` — macht einen migrierten Report auffindbar
|
||||
(Akzeptanzkriterium 1).
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| AC1 | Jeder historische Report ist nach Migration auffindbar und dem korrekten Tenant zugeordnet | **bestanden** — `TestMigrateAll_ReportsAreFindableWithOriginalContent`, `TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase` |
|
||||
| Prüfung 1 | Stichprobe von Reports vor/nach Migration inhaltlich verglichen (2 synthetische Reports, vollständig, nicht nur 5-aus-N simuliert, da Fixture bewusst klein) | **bestanden** — `TestMigrateAll_ReportsAreFindableWithOriginalContent` vergleicht `report_json` und `generated_at` jedes migrierten Reports gegen die Quelle |
|
||||
|
||||
Zusätzlich (nicht explizit als Pflichtprüfung benannt, aber zentral für
|
||||
Idempotenz/Produkt-DNA):
|
||||
|
||||
- `TestMigrateAll_SecondRunSkipsAlreadyMigratedReports` — zweiter Lauf
|
||||
gegen dieselbe Quelle überschreibt nichts, Zähler bestätigt
|
||||
(0 migriert / 2 übersprungen).
|
||||
- `TestFindBySourceID_UnknownIDReturnsErrNotFound` — unbekannte ID
|
||||
liefert definierten Fehler statt stillem Leerergebnis.
|
||||
- `TestSourceReport_IndexedAtIsNeverPersisted` — Produkt-DNA, s.o.
|
||||
|
||||
## Reale Ausführung (Host 204, echter Postgres-Testcluster)
|
||||
|
||||
Umgebungsvariablen eingespeist über
|
||||
`set -a && source /etc/nexarch-dev/tenant-dev.env && set +a` (Werte nie
|
||||
im Klartext ausgegeben, nur Namen per `grep -o '^[A-Z_]*='` geprüft).
|
||||
|
||||
```
|
||||
cd archive
|
||||
go build ./...
|
||||
go test ./internal/reconcilereportmigration/... -v
|
||||
```
|
||||
|
||||
Ergebnis: **5/5 Tests bestanden**, 0 übersprungen (TEST_TENANT_DSN und
|
||||
TEST_TENANT_DSN_B beide gesetzt und erreichbar), `go build ./...` ohne
|
||||
Fehler.
|
||||
|
||||
```
|
||||
=== RUN TestMigrateAll_ReportsAreFindableWithOriginalContent
|
||||
--- PASS: TestMigrateAll_ReportsAreFindableWithOriginalContent (0.03s)
|
||||
=== RUN TestMigrateAll_SecondRunSkipsAlreadyMigratedReports
|
||||
--- PASS: TestMigrateAll_SecondRunSkipsAlreadyMigratedReports (0.03s)
|
||||
=== RUN TestFindBySourceID_UnknownIDReturnsErrNotFound
|
||||
--- PASS: TestFindBySourceID_UnknownIDReturnsErrNotFound (0.01s)
|
||||
=== RUN TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase
|
||||
--- PASS: TestMigrateAll_ReportIsOnlyFindableInCorrectTenantDatabase (0.04s)
|
||||
=== RUN TestSourceReport_IndexedAtIsNeverPersisted
|
||||
--- PASS: TestSourceReport_IndexedAtIsNeverPersisted (0.04s)
|
||||
PASS
|
||||
ok gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcilereportmigration 0.153s
|
||||
```
|
||||
|
||||
Testdaten ausschließlich synthetisch (`mai04test_*`-Präfix, erfundene
|
||||
Storage-Keys/Inhalte), Testzeilen per `t.Cleanup` nach jedem Lauf
|
||||
gelöscht — keine echten archivmail-/archivdms-Daten verwendet.
|
||||
|
||||
## Nicht Teil dieses Tickets
|
||||
|
||||
- Keine echte SQL-Export-Implementierung von `Source` gegen die
|
||||
tatsächliche archivmail-Datenbank (keine Zugangsdaten zu einem
|
||||
System mit echten historischen Reports verfügbar/im Scope) — die
|
||||
Schnittstelle ist bewusst klein gehalten, eine reale Implementierung
|
||||
bindet nur SQL-Lesezugriffe gegen die dokumentierte Quelltabelle an,
|
||||
ohne `Migrator`/`FindBySourceID` zu ändern (gleiches Muster wie
|
||||
MAI-02).
|
||||
- Kein CLI/Cmd-Wrapper — nicht durch ein Akzeptanzkriterium gefordert,
|
||||
kleinste Lösung.
|
||||
Reference in new issue
Block a user