Files
nexarch/archive/internal/reconcile/sources_test.go
T
sysops ae214f1731 feat(archive): BAK-05 Reconciliation Storage vs. DB
Deterministischer Abgleich zwischen DMS file_revisions und
Objekt-Storage-Verzeichnis, existenz-only (keine Inhaltspruefung,
saubere Abgrenzung zu BAK-08). Report sortiert nach storage_key fuer
stabile Weiterverarbeitung durch BAK-08. Systemd-Timer taeglich,
real auf 192.168.1.131 verdrahtet und ausgeloest.
2026-08-29 23:24:02 +02:00

133 lines
4.8 KiB
Go

package reconcile
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
// Minimalschema, das exakt DMS FDN-02s file_revisions-Spalten spiegelt
// (Archive kann DMS' internal/-Pakete als eigenes Go-Modul nicht
// importieren, daher hier als Testfixture kopiert statt real migriert).
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`)
})
return pool
}
// TestListDBStorageKeys_ReadsRealFileRevisions ist der Nachweis, dass
// ListDBStorageKeys tatsaechlich gegen eine echte Postgres-Instanz mit
// DMS-FDN-02-Schema liest — kein Mock.
func TestListDBStorageKeys_ReadsRealFileRevisions(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
var userID, docID string
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('reconcile-test@example.test', 'Test') RETURNING id`).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, userID).Scan(&docID); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc', 10, 'text/plain', 1, $2)
`, docID, userID); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
entries, err := ListDBStorageKeys(ctx, pool)
if err != nil {
t.Fatalf("listdbstoragekeys: %v", err)
}
if len(entries) != 1 {
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
}
if entries[0].StorageKey != "documents/x/revisions/1" {
t.Fatalf("storage_key = %q, want %q", entries[0].StorageKey, "documents/x/revisions/1")
}
if entries[0].DocumentID != docID {
t.Fatalf("document_id = %q, want %q", entries[0].DocumentID, docID)
}
}
// TestListStorageObjects_WalksRealDirectory ist der Nachweis, dass
// ListStorageObjects tatsaechlich das Dateisystem durchlaeuft.
func TestListStorageObjects_WalksRealDirectory(t *testing.T) {
baseDir := t.TempDir()
mustWriteFile(t, filepath.Join(baseDir, "documents", "d1", "revisions", "r1"), "inhalt")
mustWriteFile(t, filepath.Join(baseDir, "documents", "d2", "revisions", "r1"), "inhalt")
keys, err := ListStorageObjects(baseDir)
if err != nil {
t.Fatalf("liststorageobjects: %v", err)
}
if len(keys) != 2 {
t.Fatalf("erwartet 2 objektschluessel, habe %d: %v", len(keys), keys)
}
want := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
for i, w := range want {
if keys[i] != w {
t.Fatalf("schluessel[%d] = %q, want %q (voll: %v)", i, keys[i], w, keys)
}
}
}
// TestListStorageObjects_MissingDirectoryReturnsEmpty prueft das
// Verhalten, wenn das Basisverzeichnis (noch) gar nicht existiert -
// sollte als "keine Objekte", nicht als Fehler behandelt werden.
func TestListStorageObjects_MissingDirectoryReturnsEmpty(t *testing.T) {
keys, err := ListStorageObjects("/pfad/der/nicht/existiert/fuer/diesen/test")
if err != nil {
t.Fatalf("erwartet keinen fehler bei fehlendem verzeichnis, habe: %v", err)
}
if len(keys) != 0 {
t.Fatalf("erwartet 0 schluessel, habe %d", len(keys))
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("datei schreiben: %v", err)
}
}