DOK-02: Akten-/Ordnerstruktur-Migration #259
No files matched your search
@@ -0,0 +1,184 @@
|
||||
// Package migrationfolder implementiert DOK-02: Uebernahme der
|
||||
// Akten-/Ordnerstruktur (archivdms akten-Tabelle) in das DMS-Zielschema
|
||||
// folders (DOC-09, Materialized Path, dms/migrations/tenant/0006). Rein
|
||||
// strukturelle, metadatenbasierte Migration ueber Source — keine
|
||||
// Binaerdaten, kein Import des trainierten ML-Klassifikator-Binaermodells
|
||||
// (Produkt-DNA). Verknuepft zusaetzlich bereits migrierte Dokumente
|
||||
// (DOK-01, dms/internal/migrationdocument) mit ihrer Ziel-Akte.
|
||||
package migrationfolder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SourceFolder ist eine Zeile aus archivdms' akten-Tabelle.
|
||||
type SourceFolder struct {
|
||||
SourceFolderID string
|
||||
SourceParentFolderID *string
|
||||
Name string
|
||||
SourceUserID string
|
||||
}
|
||||
|
||||
// SourceDocumentFolder bildet ein Quelldokument auf seine Quell-Akte ab.
|
||||
type SourceDocumentFolder struct {
|
||||
SourceDocumentID string
|
||||
SourceFolderID string
|
||||
}
|
||||
|
||||
// Source kapselt den lesenden SQL-Export-Zugriff auf archivdms.
|
||||
type Source interface {
|
||||
Folders(ctx context.Context) ([]SourceFolder, error)
|
||||
DocumentFolders(ctx context.Context) ([]SourceDocumentFolder, error)
|
||||
}
|
||||
|
||||
// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte
|
||||
// NEXARCH-Benutzer-ID ab, analog zu migrationdocument.UserResolver.
|
||||
type UserResolver interface {
|
||||
ResolveUserID(ctx context.Context, sourceUserID string) (string, error)
|
||||
}
|
||||
|
||||
// ErrCycle meldet eine zyklische Elternreferenz in der Quellhierarchie.
|
||||
var ErrCycle = errors.New("migrationfolder: zyklus in der quellhierarchie")
|
||||
|
||||
// ErrParentNotFound meldet eine Elternreferenz auf eine in der Quelle nicht
|
||||
// vorhandene Akte.
|
||||
var ErrParentNotFound = errors.New("migrationfolder: elternordner in quelle nicht gefunden")
|
||||
|
||||
// Migrator uebertraegt Akten/Ordner einer Quelle in genau einen
|
||||
// Ziel-Tenant.
|
||||
type Migrator struct {
|
||||
pool *pgxpool.Pool
|
||||
source Source
|
||||
users UserResolver
|
||||
tenantSlug string
|
||||
}
|
||||
|
||||
func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, tenantSlug string) *Migrator {
|
||||
return &Migrator{pool: pool, source: source, users: users, tenantSlug: tenantSlug}
|
||||
}
|
||||
|
||||
// Result fasst den Ausgang eines Migrationslaufs zusammen.
|
||||
type Result struct {
|
||||
FoldersMigrated int
|
||||
FoldersSkipped int
|
||||
DocumentsLinked int
|
||||
}
|
||||
|
||||
type folderMigration struct {
|
||||
byID map[string]SourceFolder
|
||||
idCache map[string]string
|
||||
pathCache map[string]string
|
||||
visiting map[string]bool
|
||||
}
|
||||
|
||||
// MigrateAll uebertraegt alle Akten/Ordner samt Hierarchie
|
||||
// (Akzeptanzkriterium 1) und verknuepft anschliessend jedes migrierte
|
||||
// Dokument mit seiner Ziel-Akte (Akzeptanzkriterium 2). Akten-IDs im Ziel
|
||||
// werden wie bei DOK-01 deterministisch aus den Quell-IDs abgeleitet, ein
|
||||
// erneuter Lauf erzeugt daher keine Duplikate.
|
||||
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
|
||||
folders, err := m.source.Folders(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationfolder: akten aus quelle lesen: %w", err)
|
||||
}
|
||||
|
||||
fm := &folderMigration{
|
||||
byID: make(map[string]SourceFolder, len(folders)),
|
||||
idCache: map[string]string{},
|
||||
pathCache: map[string]string{},
|
||||
visiting: map[string]bool{},
|
||||
}
|
||||
for _, f := range folders {
|
||||
fm.byID[f.SourceFolderID] = f
|
||||
}
|
||||
|
||||
for _, f := range folders {
|
||||
if _, _, err := m.migrateFolder(ctx, fm, f.SourceFolderID, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
docFolders, err := m.source.DocumentFolders(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationfolder: dokument-akten-zuordnung aus quelle lesen: %w", err)
|
||||
}
|
||||
for _, df := range docFolders {
|
||||
documentID := deriveUUID("document", m.tenantSlug, df.SourceDocumentID)
|
||||
folderID := deriveUUID("folder", m.tenantSlug, df.SourceFolderID)
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
UPDATE documents SET folder_id = $2 WHERE id = $1 AND folder_id IS DISTINCT FROM $2
|
||||
`, documentID, folderID)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationfolder: dokument %q mit akte %q verknuepfen: %w", df.SourceDocumentID, df.SourceFolderID, err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
res.DocumentsLinked++
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateFolder(ctx context.Context, fm *folderMigration, sourceFolderID string, res *Result) (folderID, path string, err error) {
|
||||
if id, ok := fm.idCache[sourceFolderID]; ok {
|
||||
return id, fm.pathCache[sourceFolderID], nil
|
||||
}
|
||||
if fm.visiting[sourceFolderID] {
|
||||
return "", "", fmt.Errorf("%w: %q", ErrCycle, sourceFolderID)
|
||||
}
|
||||
fm.visiting[sourceFolderID] = true
|
||||
defer delete(fm.visiting, sourceFolderID)
|
||||
|
||||
src, ok := fm.byID[sourceFolderID]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("%w: %q", ErrParentNotFound, sourceFolderID)
|
||||
}
|
||||
|
||||
folderID = deriveUUID("folder", m.tenantSlug, sourceFolderID)
|
||||
|
||||
var parentID *string
|
||||
var parentPath string
|
||||
if src.SourceParentFolderID != nil {
|
||||
pid, ppath, err := m.migrateFolder(ctx, fm, *src.SourceParentFolderID, res)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parentID = &pid
|
||||
parentPath = ppath
|
||||
}
|
||||
|
||||
if parentPath == "" {
|
||||
path = "/" + folderID + "/"
|
||||
} else {
|
||||
path = parentPath + folderID + "/"
|
||||
}
|
||||
|
||||
createdBy, err := m.users.ResolveUserID(ctx, src.SourceUserID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("migrationfolder: ersteller fuer akte %q aufloesen: %w", sourceFolderID, err)
|
||||
}
|
||||
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO folders (id, parent_folder_id, name, path, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, folderID, parentID, src.Name, path, createdBy)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("migrationfolder: akte %q anlegen: %w", sourceFolderID, err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
res.FoldersMigrated++
|
||||
} else {
|
||||
res.FoldersSkipped++
|
||||
}
|
||||
|
||||
fm.idCache[sourceFolderID] = folderID
|
||||
fm.pathCache[sourceFolderID] = path
|
||||
return folderID, path, nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package migrationfolder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrationfolder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// deriveUUID muss bitidentisch zu dms/internal/migrationdocument.deriveUUID
|
||||
// sein (gleicher Namespace "document" => gleiche Ziel-ID), damit DOK-02
|
||||
// Dokumente ueber dieselbe deterministische ID wie DOK-01 wiederfindet,
|
||||
// ohne eine Mapping-Tabelle mitschleppen zu muessen.
|
||||
func deriveUUID(namespace string, parts ...string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(namespace))
|
||||
for _, p := range parts {
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(p))
|
||||
}
|
||||
sum := h.Sum(nil)
|
||||
|
||||
sum[6] = (sum[6] & 0x0f) | 0x50
|
||||
sum[8] = (sum[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# DOK-02 – Prüfprotokoll: Akten-/Ordnerstruktur-Migration
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Neues Paket `dms/internal/migrationfolder` (analog zu DOK-01s
|
||||
`dms/internal/migrationdocument`): migriert archivdms' akten-Tabelle
|
||||
strukturell 1:1 nach DMS DOC-09 (`folders`, Materialized Path,
|
||||
`dms/migrations/tenant/0006_folders_materialized_path.up.sql`) und
|
||||
verknüpft anschließend bereits migrierte Dokumente (DOK-01) mit ihrer
|
||||
Ziel-Akte über `documents.folder_id`.
|
||||
|
||||
- Ziel-IDs werden wie bei DOK-01 deterministisch aus den Quell-IDs
|
||||
abgeleitet (`deriveUUID`, Namespace `"folder"` bzw. `"document"`) —
|
||||
ein erneuter Lauf gegen dieselbe Quelle erzeugt keine Duplikate, ohne
|
||||
separate Mapping-Tabelle.
|
||||
- Hierarchie- und Pfadaufbau erfolgt rekursiv über die Quell-Eltern-
|
||||
referenzen (Memoisierung je Akte), mit Zyklus-Erkennung
|
||||
(`ErrCycle`) statt Endlosrekursion.
|
||||
- Bewusst NICHT migriert: das trainierte ML-Klassifikator-Binärmodell
|
||||
(Produkt-DNA-Vorgabe) — dieses Paket übernimmt ausschließlich die
|
||||
strukturellen Metadaten der Akten/Ordner.
|
||||
|
||||
## DSGVO: Substitution durch synthetische Daten
|
||||
|
||||
Gemäß Produktarchitektur- und Sicherheitskonzept-Vorgaben wurde
|
||||
**ausschließlich synthetisches Testmaterial** verwendet, niemals ein
|
||||
Export aus archivdms oder einem anderen Altsystem:
|
||||
|
||||
- Die Testquelle (`memorySource` in
|
||||
`dms/internal/migrationfolder/migrate_test.go`) ist eine rein
|
||||
In-Memory erfundene Akten-Hierarchie ("Mandant Mustermann" →
|
||||
"Vorgang Hauskauf" → "Unterlagen", "Mandant Schmidt") mit frei
|
||||
erfundenen Quell-IDs (`akte-mandant-mustermann` usw.) und einem
|
||||
generischen Platzhalterbenutzer (`archivdms-user-1`).
|
||||
- Es bestand zu keinem Zeitpunkt eine Verbindung zu `archivdms`
|
||||
(Host/DB tabu laut Auftrag). Die Zieldatenbank war die
|
||||
Wegwerf-Testdatenbank `dms_dev` auf Host 192.168.1.204, dort
|
||||
jeweils in einem frisch angelegten, isolierten Schema
|
||||
(`dms/internal/testdb`), das nach dem Testlauf automatisch wieder
|
||||
entfernt wird.
|
||||
- Kein Kontakt zu Host 192.168.1.131.
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
### Prüfung 1 — Stichprobe von 10 Aktenpfaden vor/nach Migration verglichen
|
||||
|
||||
Mangels Zugriffs auf echte archivdms-Daten (DSGVO-Vorgabe) wurde diese
|
||||
Prüfung gegen die synthetische Fixture durchgeführt und zusätzlich mit
|
||||
einer erweiterten 20-Ebenen-Tiefenprobe in
|
||||
`dms/internal/folder/folder_test.go`
|
||||
(`TestCreate_ArbitraryDepthHierarchy`, bereits vorhandener DOC-09-Test)
|
||||
abgedeckt. Für DOK-02 selbst prüft
|
||||
`TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten` exemplarisch:
|
||||
|
||||
| Quell-Aktenpfad (archivdms, synthetisch) | Ziel-Pfad (DMS, Materialized Path) | Ergebnis |
|
||||
|---|---|---|
|
||||
| `akte-mandant-mustermann` | `/<id-mustermann>/` | übereinstimmend |
|
||||
| `akte-mandant-mustermann/akte-vorgang-kauf` | `/<id-mustermann>/<id-kauf>/` | übereinstimmend (Präfix erhalten) |
|
||||
| `.../akte-vorgang-kauf/akte-vorgang-kauf-unterlagen` | `/<id-mustermann>/<id-kauf>/<id-unterlagen>/` | übereinstimmend (Präfix erhalten) |
|
||||
| `akte-mandant-schmidt` | `/<id-schmidt>/` | übereinstimmend |
|
||||
|
||||
Ergebnis: **PASS** — Hierarchie- und Elternreferenzen stimmen zwischen
|
||||
Quelle und Ziel überein (`parent_folder_id`, Pfad-Präfixe geprüft per
|
||||
Test-Assertion). Eine vollständige 10-Pfad-Stichprobe gegen echte
|
||||
Produktionsdaten ist nach dem Rollout auf echter archivdms-Quelle
|
||||
nachzuholen (außerhalb des Scopes dieser Session, siehe Open Items).
|
||||
|
||||
### Prüfung 2 — Anzahl Ordner/Akten je Tenant deckungsgleich mit Quelle
|
||||
|
||||
`TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten` zählt die
|
||||
migrierten Akten (`res.FoldersMigrated == 4`) gegen die tatsächliche
|
||||
Zeilenzahl in `folders` (`SELECT count(*) FROM folders == 4`) für den
|
||||
Ziel-Tenant — deckungsgleich mit den 4 Quell-Akten der Fixture.
|
||||
`TestMigrateAll_Wiederholbar` bestätigt zusätzlich, dass ein erneuter
|
||||
Lauf die Zählung nicht verändert (keine Duplikate je Tenant).
|
||||
|
||||
Ergebnis: **PASS**.
|
||||
|
||||
## Automatisierte Tests (Akzeptanzkriterien)
|
||||
|
||||
Ausgeführt auf Testhost `root@192.168.1.204` gegen die Wegwerf-DB
|
||||
`dms_dev` (isoliertes Schema je Testlauf):
|
||||
|
||||
```
|
||||
go test ./internal/migrationfolder/... -v
|
||||
=== RUN TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten
|
||||
--- PASS (0.17s)
|
||||
=== RUN TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft
|
||||
--- PASS (0.16s)
|
||||
=== RUN TestMigrateAll_Wiederholbar
|
||||
--- PASS (0.16s)
|
||||
=== RUN TestMigrateAll_ZyklusWirdErkannt
|
||||
--- PASS (0.11s)
|
||||
PASS
|
||||
```
|
||||
|
||||
Zusätzlich als Regressionskontrolle (keine Seiteneffekte auf
|
||||
Nachbarpakete) ausgeführt und PASS:
|
||||
`./internal/folder/...`, `./internal/migrationdocument/...`.
|
||||
`go vet ./...` (gesamtes Repo-Modul) ohne Befunde.
|
||||
|
||||
## Akzeptanzkriterien — Status
|
||||
|
||||
1. Jede Akte/jeder Ordner ist nach der Migration mit derselben
|
||||
Hierarchie wie im Altsystem vorhanden — **erfüllt** (Prüfung 1).
|
||||
2. Jedes migrierte Dokument ist der korrekten Akte zugeordnet —
|
||||
**erfüllt** (`TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft`,
|
||||
Prüfung 2 indirekt über Zähl-Deckungsgleichheit).
|
||||
|
||||
## Entscheidungen / Open Items
|
||||
|
||||
- Entscheidung: `DocumentFolders` als eigene Source-Methode (statt
|
||||
Erweiterung von `SourceDocument` aus DOK-01), damit
|
||||
`migrationfolder` unabhängig von `migrationdocument`s Source-
|
||||
Interface bleibt und beide Migrationsschritte unabhängig
|
||||
voneinander lauffähig sind (DOK-01 bereits gemergt, kein Eingriff
|
||||
in dessen Interface).
|
||||
- Entscheidung: `deriveUUID` in `migrationfolder` dupliziert (statt
|
||||
aus `migrationdocument` exportiert) — kleinste Änderung, kein
|
||||
Export-Refactor eines bereits gemergten Pakets nötig; Algorithmus
|
||||
ist bitidentisch (gleicher Namespace-String ergibt dieselbe ID).
|
||||
- Open Item: Echte 10-Pfad-Stichprobe gegen eine tatsächliche
|
||||
archivdms-Instanz ist laut DSGVO-Vorgabe dieser Session nicht
|
||||
durchführbar und somit außerhalb des Scopes; vor produktivem
|
||||
Rollout nachzuholen, sobald eine kontrollierte archivdms-Quelle
|
||||
verfügbar ist (gleiches Vorgehen wie bei DOK-01 vermutlich bereits
|
||||
etabliert).
|
||||
Reference in new issue
Block a user