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) Failing after 1m2s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 22s
CI / Web (dms-browser) (push) Failing after 1m22s
CI / Web (pdf-viewer) (push) Failing after 53s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 1m6s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m22s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m56s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m16s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m46s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m24s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m44s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
8.7 KiB
Go
238 lines
8.7 KiB
Go
package migrationfolder
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
|
)
|
|
|
|
// memorySource ist eine In-Memory-Testquelle: simuliert das SQL-Export von
|
|
// archivdms' akten-Tabelle rein SYNTHETISCH — fake Hierarchie, keine
|
|
// echten archivdms-Daten (Produktarchitektur-Vorgabe, DSGVO).
|
|
type memorySource struct {
|
|
folders []SourceFolder
|
|
documentFolders []SourceDocumentFolder
|
|
}
|
|
|
|
func (s *memorySource) Folders(ctx context.Context) ([]SourceFolder, error) {
|
|
return s.folders, nil
|
|
}
|
|
|
|
func (s *memorySource) DocumentFolders(ctx context.Context) ([]SourceDocumentFolder, error) {
|
|
return s.documentFolders, nil
|
|
}
|
|
|
|
type staticUserResolver struct{ userID string }
|
|
|
|
func (r staticUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
|
|
return r.userID, nil
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func newSyntheticSource() *memorySource {
|
|
return &memorySource{
|
|
folders: []SourceFolder{
|
|
{SourceFolderID: "akte-mandant-mustermann", SourceParentFolderID: nil, Name: "Mandant Mustermann", SourceUserID: "archivdms-user-1"},
|
|
{SourceFolderID: "akte-vorgang-kauf", SourceParentFolderID: strPtr("akte-mandant-mustermann"), Name: "Vorgang Hauskauf", SourceUserID: "archivdms-user-1"},
|
|
{SourceFolderID: "akte-vorgang-kauf-unterlagen", SourceParentFolderID: strPtr("akte-vorgang-kauf"), Name: "Unterlagen", SourceUserID: "archivdms-user-1"},
|
|
{SourceFolderID: "akte-mandant-schmidt", SourceParentFolderID: nil, Name: "Mandant Schmidt", SourceUserID: "archivdms-user-1"},
|
|
},
|
|
documentFolders: []SourceDocumentFolder{
|
|
{SourceDocumentID: "archivdms-doc-1", SourceFolderID: "akte-vorgang-kauf-unterlagen"},
|
|
{SourceDocumentID: "archivdms-doc-2", SourceFolderID: "akte-mandant-schmidt"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func setupMigratorTest(t *testing.T) (*Migrator, *pgxpool.Pool, string) {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
if dsn == "" {
|
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool := testdb.Pool(t, dsn)
|
|
t.Cleanup(func() { pool.Close() })
|
|
|
|
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 folders (
|
|
id UUID PRIMARY KEY, parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL, path TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_folders_path ON folders (path text_pattern_ops);
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
id UUID PRIMARY KEY, folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, title TEXT NOT NULL,
|
|
created_by UUID NOT NULL REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx := context.Background()
|
|
_, _ = pool.Exec(ctx, `TRUNCATE documents, folders, users CASCADE`)
|
|
})
|
|
|
|
var userID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id
|
|
`, fmt.Sprintf("dok02-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
|
|
t.Fatalf("testbenutzer anlegen: %v", err)
|
|
}
|
|
|
|
migrator := NewMigrator(pool, newSyntheticSource(), staticUserResolver{userID: userID}, "acme-test")
|
|
|
|
for _, docID := range []string{"archivdms-doc-1", "archivdms-doc-2"} {
|
|
targetID := deriveUUID("document", "acme-test", docID)
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO documents (id, title, created_by) VALUES ($1, $2, $3)
|
|
`, targetID, "Testdokument "+docID, userID); err != nil {
|
|
t.Fatalf("testdokument %q anlegen: %v", docID, err)
|
|
}
|
|
}
|
|
|
|
return migrator, pool, userID
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: jede Akte/jeder Ordner ist nach der Migration mit
|
|
// derselben Hierarchie wie im Altsystem vorhanden.
|
|
func TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten(t *testing.T) {
|
|
migrator, pool, _ := setupMigratorTest(t)
|
|
ctx := context.Background()
|
|
|
|
res, err := migrator.MigrateAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("MigrateAll: %v", err)
|
|
}
|
|
if res.FoldersMigrated != 4 {
|
|
t.Fatalf("erwartet 4 migrierte akten, habe %+v", res)
|
|
}
|
|
|
|
var folderCount int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM folders`).Scan(&folderCount); err != nil {
|
|
t.Fatalf("akten zaehlen: %v", err)
|
|
}
|
|
if folderCount != 4 {
|
|
t.Fatalf("erwartet 4 akten in der db, habe %d", folderCount)
|
|
}
|
|
|
|
rootID := deriveUUID("folder", "acme-test", "akte-mandant-mustermann")
|
|
childID := deriveUUID("folder", "acme-test", "akte-vorgang-kauf")
|
|
grandchildID := deriveUUID("folder", "acme-test", "akte-vorgang-kauf-unterlagen")
|
|
|
|
var parentFolderID *string
|
|
var path string
|
|
if err := pool.QueryRow(ctx, `SELECT parent_folder_id, path FROM folders WHERE id = $1`, childID).Scan(&parentFolderID, &path); err != nil {
|
|
t.Fatalf("kind-akte lesen: %v", err)
|
|
}
|
|
if parentFolderID == nil || *parentFolderID != rootID {
|
|
t.Fatalf("erwartet parent_folder_id %q, habe %v", rootID, parentFolderID)
|
|
}
|
|
if !strings.Contains(path, "/"+rootID+"/") || !strings.HasSuffix(path, "/"+childID+"/") {
|
|
t.Fatalf("pfad der kind-akte spiegelt hierarchie nicht wider: %q", path)
|
|
}
|
|
|
|
var grandchildPath string
|
|
if err := pool.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, grandchildID).Scan(&grandchildPath); err != nil {
|
|
t.Fatalf("enkel-akte lesen: %v", err)
|
|
}
|
|
expectedPrefix := "/" + rootID + "/" + childID + "/"
|
|
if !strings.HasPrefix(grandchildPath, expectedPrefix) {
|
|
t.Fatalf("erwartet pfad-praefix %q, habe %q", expectedPrefix, grandchildPath)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2: jedes migrierte Dokument ist der korrekten Akte
|
|
// zugeordnet.
|
|
func TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft(t *testing.T) {
|
|
migrator, pool, _ := setupMigratorTest(t)
|
|
ctx := context.Background()
|
|
|
|
res, err := migrator.MigrateAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("MigrateAll: %v", err)
|
|
}
|
|
if res.DocumentsLinked != 2 {
|
|
t.Fatalf("erwartet 2 verknuepfte dokumente, habe %+v", res)
|
|
}
|
|
|
|
doc1ID := deriveUUID("document", "acme-test", "archivdms-doc-1")
|
|
expectedFolder1 := deriveUUID("folder", "acme-test", "akte-vorgang-kauf-unterlagen")
|
|
var folderID1 string
|
|
if err := pool.QueryRow(ctx, `SELECT folder_id FROM documents WHERE id = $1`, doc1ID).Scan(&folderID1); err != nil {
|
|
t.Fatalf("dokument 1 lesen: %v", err)
|
|
}
|
|
if folderID1 != expectedFolder1 {
|
|
t.Fatalf("dokument 1 ist der falschen akte zugeordnet: erwartet %q, habe %q", expectedFolder1, folderID1)
|
|
}
|
|
|
|
doc2ID := deriveUUID("document", "acme-test", "archivdms-doc-2")
|
|
expectedFolder2 := deriveUUID("folder", "acme-test", "akte-mandant-schmidt")
|
|
var folderID2 string
|
|
if err := pool.QueryRow(ctx, `SELECT folder_id FROM documents WHERE id = $1`, doc2ID).Scan(&folderID2); err != nil {
|
|
t.Fatalf("dokument 2 lesen: %v", err)
|
|
}
|
|
if folderID2 != expectedFolder2 {
|
|
t.Fatalf("dokument 2 ist der falschen akte zugeordnet: erwartet %q, habe %q", expectedFolder2, folderID2)
|
|
}
|
|
}
|
|
|
|
// Wiederholbarkeit: ein erneuter Lauf gegen dieselbe Quelle erzeugt keine
|
|
// Duplikate (gleiches Prinzip wie DOK-01/migrationdocument).
|
|
func TestMigrateAll_Wiederholbar(t *testing.T) {
|
|
migrator, pool, _ := setupMigratorTest(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := migrator.MigrateAll(ctx); err != nil {
|
|
t.Fatalf("erster lauf: %v", err)
|
|
}
|
|
res2, err := migrator.MigrateAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("zweiter lauf: %v", err)
|
|
}
|
|
if res2.FoldersMigrated != 0 || res2.FoldersSkipped != 4 {
|
|
t.Fatalf("zweiter lauf haette keine neuen akten anlegen duerfen, habe %+v", res2)
|
|
}
|
|
|
|
var folderCount int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM folders`).Scan(&folderCount); err != nil {
|
|
t.Fatalf("akten zaehlen: %v", err)
|
|
}
|
|
if folderCount != 4 {
|
|
t.Fatalf("erneuter lauf hat duplikate erzeugt: %d akten", folderCount)
|
|
}
|
|
}
|
|
|
|
// Pruefung: zyklische Elternreferenz in der Quelle wird erkannt statt eine
|
|
// Endlosrekursion auszuloesen.
|
|
func TestMigrateAll_ZyklusWirdErkannt(t *testing.T) {
|
|
migrator, _, _ := setupMigratorTest(t)
|
|
src := migrator.source.(*memorySource)
|
|
src.folders = []SourceFolder{
|
|
{SourceFolderID: "a", SourceParentFolderID: strPtr("b"), Name: "A", SourceUserID: "archivdms-user-1"},
|
|
{SourceFolderID: "b", SourceParentFolderID: strPtr("a"), Name: "B", SourceUserID: "archivdms-user-1"},
|
|
}
|
|
src.documentFolders = nil
|
|
|
|
ctx := context.Background()
|
|
_, err := migrator.MigrateAll(ctx)
|
|
if err == nil || !strings.Contains(err.Error(), "zyklus") {
|
|
t.Fatalf("erwartet zyklus-fehler, habe %v", err)
|
|
}
|
|
}
|