DOK-04: Custom-Fields- und Klassifikationsvorlagen-Migration #264
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# DOK-04 – Prüfprotokoll
|
||||
|
||||
Migration: `dms/internal/migrationmetadata` — Custom Fields (MET-02,
|
||||
`metadata_fields`/`metadata_field_versions`) und Klassifikationsvorlagen
|
||||
(MET-04, `document_types`/`document_type_fields`) aus archivdms, inklusive
|
||||
bereits befüllter Feldwerte an durch DOK-01 migrierten Dokumenten
|
||||
(`document_metadata_values`). Keine neue SQL-Migration nötig — alle
|
||||
Zieltabellen existieren bereits (MET-02/MET-04 sind fertig gemergt).
|
||||
|
||||
## Prüfung 1 — Stichprobe von 5 synthetischen Vorlagen/Feldern vor/nach Migration
|
||||
|
||||
Synthetische Quelle (`dms/internal/migrationmetadata/migrate_test.go`,
|
||||
`newSyntheticSource`): 5 Custom Fields (Text/Datum/Zahl/Bool/Auswahl, je mit
|
||||
Pflichtkennzeichen, Standardwert bzw. Validierungsregel) und 5 Klassifikations-
|
||||
vorlagen (Name, Aufbewahrungsklasse, gebündelte Felder).
|
||||
|
||||
Ausführung: `go test ./internal/migrationmetadata/... -run TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition -v`
|
||||
gegen `dms_dev` (Host 192.168.1.204).
|
||||
|
||||
Zielwert: Label, Feldtyp und Pflichtkennzeichen jedes der 5 Felder sowie Name,
|
||||
Aufbewahrungsklasse und Anzahl gebündelter Felder jeder der 5 Vorlagen stimmen
|
||||
nach der Migration exakt mit der Quelle überein.
|
||||
|
||||
Ergebnis: **PASS** (`TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition`,
|
||||
0,29s, 2026-10-14).
|
||||
|
||||
## Prüfung 2 — Feldwerte einer Stichprobe migrierter Dokumente gegen Quelle
|
||||
|
||||
Synthetische Quelle: 3 Feldwerte an 2 synthetischen Dokumenten, deren IDs
|
||||
über dieselbe deterministische Ableitung (`deriveUUID("document", ...)`) wie
|
||||
in DOK-01 erzeugt werden — simuliert den Zustand nach einer vorangegangenen
|
||||
DOK-01-Dokumentenmigration.
|
||||
|
||||
Ausführung: `go test ./internal/migrationmetadata/... -run TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten -v`
|
||||
gegen `dms_dev`.
|
||||
|
||||
Zielwert: alle 3 Feldwerte sind nach der Migration unter der korrekten
|
||||
Dokument-/Feld-ID-Kombination mit identischem Wert vorhanden.
|
||||
|
||||
Ergebnis: **PASS** (`TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten`,
|
||||
0,25s, 2026-10-14).
|
||||
|
||||
## Zusatzprüfung — Wiederholbarkeit
|
||||
|
||||
`TestMigrateAll_AkzeptanzkriteriumWiederholbar`: ein zweiter Lauf gegen
|
||||
dieselbe Quelle migriert 0 neue Felder/Vorlagen/Werte und erzeugt keine
|
||||
Duplikate (deterministische IDs, `ON CONFLICT DO NOTHING`).
|
||||
|
||||
Ergebnis: **PASS** (0,25s, 2026-10-14).
|
||||
|
||||
## Regressionsprüfung
|
||||
|
||||
`go build ./internal/migrationmetadata/...`, `go vet ./internal/migrationmetadata/...`
|
||||
sowie die Bestandstests `./internal/metadatafield/...` und
|
||||
`./internal/documenttype/...` liefen nach der Änderung unverändert grün
|
||||
(10 Tests, alle PASS) — keine Rückwirkung auf MET-02/MET-04 selbst.
|
||||
|
||||
## Umgebung
|
||||
|
||||
Host 192.168.1.204 (Wegwerf-Checkout `/root/dok04-work-<timestamp>`, nach
|
||||
Testlauf entfernt), DB `dms_dev`, Go 1.26.5 / Postgres 17.10. Ausschließlich
|
||||
synthetische Testfixtures, keine echten archivdms-Daten.
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package migrationmetadata implementiert DOK-04: Uebernahme von
|
||||
// Custom-Fields (MET-02, dms/internal/metadatafield) und Klassifikations-
|
||||
// vorlagen (MET-04, dms/internal/documenttype) aus archivdms, inklusive der
|
||||
// bereits an migrierten Dokumenten (DOK-01) befuellten Feldwerte. Reines
|
||||
// SQL-Metadaten-Geschaeft, keine Binaerdaten (Produkt-DNA). Das trainierte
|
||||
// ML-Klassifikator-Modell selbst wird bewusst NICHT migriert (siehe DOK-01).
|
||||
package migrationmetadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func marshalRule(rule map[string]any) ([]byte, error) {
|
||||
if rule == nil {
|
||||
rule = map[string]any{}
|
||||
}
|
||||
return json.Marshal(rule)
|
||||
}
|
||||
|
||||
// SourceCustomField ist eine Zeile aus archivdms' Felddefinitionstabelle
|
||||
// (SQL-Export), Gegenstueck zu metadatafield.Field.
|
||||
type SourceCustomField struct {
|
||||
SourceFieldID string
|
||||
Key string
|
||||
Label string
|
||||
Type string
|
||||
Required bool
|
||||
DefaultValue *string
|
||||
ValidationRule map[string]any
|
||||
}
|
||||
|
||||
// SourceClassificationTemplate ist eine Zeile aus archivdms' Klassifikations-
|
||||
// vorlagentabelle, Gegenstueck zu documenttype.DocumentType. Tag-Buendelung
|
||||
// ist nicht Bestandteil von DOK-04 (eigenes Tag-Migrationsticket).
|
||||
type SourceClassificationTemplate struct {
|
||||
SourceTemplateID string
|
||||
Name string
|
||||
RetentionClass *string
|
||||
SourceFieldIDs []string
|
||||
}
|
||||
|
||||
// SourceFieldValue ist ein bereits in archivdms befuellter Feldwert an einem
|
||||
// Dokument, das DOK-01 bereits in dieses Ziel-Tenant migriert hat.
|
||||
type SourceFieldValue struct {
|
||||
SourceDocumentID string
|
||||
SourceFieldID string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Source kapselt den lesenden Zugriff auf archivdms' Custom-Field-/
|
||||
// Klassifikationsvorlagen-Schema. Eine echte Implementierung liest gegen
|
||||
// archivdms, Tests verwenden ausschliesslich synthetische Fixtures.
|
||||
type Source interface {
|
||||
CustomFields(ctx context.Context) ([]SourceCustomField, error)
|
||||
ClassificationTemplates(ctx context.Context) ([]SourceClassificationTemplate, error)
|
||||
FieldValues(ctx context.Context) ([]SourceFieldValue, error)
|
||||
}
|
||||
|
||||
// Migrator uebertraegt Custom Fields, Klassifikationsvorlagen und Feldwerte
|
||||
// einer Quelle in genau einen Ziel-Tenant.
|
||||
type Migrator struct {
|
||||
pool *pgxpool.Pool
|
||||
source Source
|
||||
tenantSlug string
|
||||
}
|
||||
|
||||
func NewMigrator(pool *pgxpool.Pool, source Source, tenantSlug string) *Migrator {
|
||||
return &Migrator{pool: pool, source: source, tenantSlug: tenantSlug}
|
||||
}
|
||||
|
||||
// Result fasst den Ausgang eines Migrationslaufs zusammen.
|
||||
type Result struct {
|
||||
CustomFieldsMigrated int
|
||||
TemplatesMigrated int
|
||||
FieldValuesMigrated int
|
||||
}
|
||||
|
||||
// MigrateAll uebertraegt zuerst die Custom Fields, dann die Klassifikations-
|
||||
// vorlagen (die Felder referenzieren), dann die Feldwerte (die Dokumente
|
||||
// referenzieren). Ziel-IDs werden deterministisch aus den Quell-IDs
|
||||
// abgeleitet (deriveUUID) — ein erneuter Lauf trifft so immer dieselben
|
||||
// Zeilen und erzeugt keine Duplikate.
|
||||
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
|
||||
fields, err := m.source.CustomFields(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationmetadata: custom fields aus quelle lesen: %w", err)
|
||||
}
|
||||
for _, f := range fields {
|
||||
migrated, err := m.migrateCustomField(ctx, f)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if migrated {
|
||||
res.CustomFieldsMigrated++
|
||||
}
|
||||
}
|
||||
|
||||
templates, err := m.source.ClassificationTemplates(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationmetadata: klassifikationsvorlagen aus quelle lesen: %w", err)
|
||||
}
|
||||
for _, t := range templates {
|
||||
migrated, err := m.migrateTemplate(ctx, t)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if migrated {
|
||||
res.TemplatesMigrated++
|
||||
}
|
||||
}
|
||||
|
||||
values, err := m.source.FieldValues(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrationmetadata: feldwerte aus quelle lesen: %w", err)
|
||||
}
|
||||
for _, v := range values {
|
||||
migrated, err := m.migrateFieldValue(ctx, v)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if migrated {
|
||||
res.FieldValuesMigrated++
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateCustomField(ctx context.Context, f SourceCustomField) (bool, error) {
|
||||
fieldID := deriveUUID("metadatafield", m.tenantSlug, f.SourceFieldID)
|
||||
ruleJSON, err := marshalRule(f.ValidationRule)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: validierungsregel fuer feld %q serialisieren: %w", f.SourceFieldID, err)
|
||||
}
|
||||
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO metadata_fields (id, field_key, label, field_type, required, default_value, validation_rule, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 1)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, fieldID, f.Key, f.Label, f.Type, f.Required, f.DefaultValue, ruleJSON)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: custom field %q anlegen: %w", f.SourceFieldID, err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if _, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO metadata_field_versions (field_id, version, label, field_type, required, default_value, validation_rule)
|
||||
VALUES ($1, 1, $2, $3, $4, $5, $6)
|
||||
`, fieldID, f.Label, f.Type, f.Required, f.DefaultValue, ruleJSON); err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: versionshistorie fuer feld %q schreiben: %w", f.SourceFieldID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateTemplate(ctx context.Context, t SourceClassificationTemplate) (bool, error) {
|
||||
templateID := deriveUUID("documenttype", m.tenantSlug, t.SourceTemplateID)
|
||||
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO document_types (id, name, retention_class) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, templateID, t.Name, t.RetentionClass)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: klassifikationsvorlage %q anlegen: %w", t.SourceTemplateID, err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, sourceFieldID := range t.SourceFieldIDs {
|
||||
fieldID := deriveUUID("metadatafield", m.tenantSlug, sourceFieldID)
|
||||
if _, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO document_type_fields (document_type_id, field_id) VALUES ($1, $2)
|
||||
ON CONFLICT (document_type_id, field_id) DO NOTHING
|
||||
`, templateID, fieldID); err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: feld %q an vorlage %q binden: %w", sourceFieldID, t.SourceTemplateID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateFieldValue(ctx context.Context, v SourceFieldValue) (bool, error) {
|
||||
documentID := deriveUUID("document", m.tenantSlug, v.SourceDocumentID)
|
||||
fieldID := deriveUUID("metadatafield", m.tenantSlug, v.SourceFieldID)
|
||||
|
||||
tag, err := m.pool.Exec(ctx, `
|
||||
INSERT INTO document_metadata_values (document_id, field_id, value) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (document_id, field_id) DO NOTHING
|
||||
`, documentID, fieldID, v.Value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("migrationmetadata: feldwert fuer dokument %q/feld %q schreiben: %w", v.SourceDocumentID, v.SourceFieldID, err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package migrationmetadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb"
|
||||
)
|
||||
|
||||
// memorySource ist eine In-Memory-Testquelle: simuliert archivdms' Custom-
|
||||
// Field-/Klassifikationsvorlagen-Export rein synthetisch (Produktarchitektur-
|
||||
// Vorgabe: keine echten archivdms-Daten in Tests).
|
||||
type memorySource struct {
|
||||
fields []SourceCustomField
|
||||
templates []SourceClassificationTemplate
|
||||
values []SourceFieldValue
|
||||
}
|
||||
|
||||
func (s *memorySource) CustomFields(ctx context.Context) ([]SourceCustomField, error) {
|
||||
return s.fields, nil
|
||||
}
|
||||
|
||||
func (s *memorySource) ClassificationTemplates(ctx context.Context) ([]SourceClassificationTemplate, error) {
|
||||
return s.templates, nil
|
||||
}
|
||||
|
||||
func (s *memorySource) FieldValues(ctx context.Context) ([]SourceFieldValue, error) {
|
||||
return s.values, nil
|
||||
}
|
||||
|
||||
// newSyntheticSource liefert 5 synthetische Vorlagen/Felder (Pruefung 1:
|
||||
// Stichprobe von 5), plus Feldwerte an zwei synthetischen, bereits
|
||||
// migrierten Dokumenten.
|
||||
func newSyntheticSource() *memorySource {
|
||||
return &memorySource{
|
||||
fields: []SourceCustomField{
|
||||
{SourceFieldID: "archivdms-field-1", Key: "aktenzeichen", Label: "Aktenzeichen", Type: "text", Required: true, ValidationRule: map[string]any{"pattern": "^[A-Z0-9-]+$"}},
|
||||
{SourceFieldID: "archivdms-field-2", Key: "faelligkeit", Label: "Faelligkeit", Type: "date", Required: false},
|
||||
{SourceFieldID: "archivdms-field-3", Key: "betrag", Label: "Betrag", Type: "number", Required: false, ValidationRule: map[string]any{"min": float64(0)}},
|
||||
{SourceFieldID: "archivdms-field-4", Key: "geprueft", Label: "Geprueft", Type: "bool", Required: false, DefaultValue: strPtr("false")},
|
||||
{SourceFieldID: "archivdms-field-5", Key: "kategorie", Label: "Kategorie", Type: "select", Required: false, ValidationRule: map[string]any{"options": []any{"privat", "geschaeftlich"}}},
|
||||
},
|
||||
templates: []SourceClassificationTemplate{
|
||||
{SourceTemplateID: "archivdms-tpl-1", Name: "Rechnungseingang", RetentionClass: strPtr("10-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-3"}},
|
||||
{SourceTemplateID: "archivdms-tpl-2", Name: "Vertrag", RetentionClass: strPtr("30-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-2"}},
|
||||
{SourceTemplateID: "archivdms-tpl-3", Name: "Korrespondenz", RetentionClass: nil, SourceFieldIDs: []string{"archivdms-field-5"}},
|
||||
{SourceTemplateID: "archivdms-tpl-4", Name: "Mahnung", RetentionClass: strPtr("6-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-3", "archivdms-field-4"}},
|
||||
{SourceTemplateID: "archivdms-tpl-5", Name: "Sonstiges", RetentionClass: nil, SourceFieldIDs: nil},
|
||||
},
|
||||
values: []SourceFieldValue{
|
||||
{SourceDocumentID: "archivdms-doc-1", SourceFieldID: "archivdms-field-1", Value: "AZ-2024-001"},
|
||||
{SourceDocumentID: "archivdms-doc-1", SourceFieldID: "archivdms-field-3", Value: "199.99"},
|
||||
{SourceDocumentID: "archivdms-doc-2", SourceFieldID: "archivdms-field-1", Value: "AZ-2024-002"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func setupMigratorTest(t *testing.T) (*Migrator, *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 := 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 documents (
|
||||
id UUID PRIMARY KEY, folder_id UUID, title TEXT NOT NULL, current_revision_id UUID,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metadata_fields (
|
||||
id UUID PRIMARY KEY, field_key TEXT NOT NULL UNIQUE, label TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'bool', 'select')),
|
||||
required BOOLEAN NOT NULL DEFAULT false, default_value TEXT, validation_rule JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
version INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metadata_field_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
|
||||
version INT NOT NULL, label TEXT NOT NULL, field_type TEXT NOT NULL, required BOOLEAN NOT NULL,
|
||||
default_value TEXT, validation_rule JSONB NOT NULL DEFAULT '{}'::jsonb, changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (field_id, version)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_metadata_values (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
|
||||
value TEXT NOT NULL, PRIMARY KEY (document_id, field_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_types (
|
||||
id UUID PRIMARY KEY, name TEXT NOT NULL UNIQUE, retention_class TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_type_fields (
|
||||
document_type_id UUID NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (document_type_id, field_id)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
_, _ = pool.Exec(ctx, `TRUNCATE document_type_fields, document_types, document_metadata_values, metadata_field_versions, metadata_fields, documents, users CASCADE`)
|
||||
})
|
||||
|
||||
var userID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id
|
||||
`, fmt.Sprintf("dok04-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
|
||||
t.Fatalf("testbenutzer anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Dokumente, an denen bereits Feldwerte haengen, simulieren den Stand
|
||||
// NACH DOK-01 — dieselbe deterministische Ableitung ("document"-Namespace)
|
||||
// wie dort liefert hier wie dort dieselbe ID.
|
||||
for _, sourceDocID := range []string{"archivdms-doc-1", "archivdms-doc-2"} {
|
||||
documentID := deriveUUID("document", "acme-test", sourceDocID)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO documents (id, title, created_by) VALUES ($1, $2, $3)
|
||||
`, documentID, "Testakte "+sourceDocID, userID); err != nil {
|
||||
t.Fatalf("testdokument %q anlegen: %v", sourceDocID, err)
|
||||
}
|
||||
}
|
||||
|
||||
migrator := NewMigrator(pool, newSyntheticSource(), "acme-test")
|
||||
return migrator, pool
|
||||
}
|
||||
|
||||
// TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition prueft
|
||||
// Akzeptanzkriterium 1 (Pruefung 1: Stichprobe von 5 Vorlagen/Feldern
|
||||
// vor/nach Migration verglichen): jedes Custom Field und jede Klassifikations-
|
||||
// vorlage ist nach der Migration mit identischer Definition vorhanden.
|
||||
func TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition(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.CustomFieldsMigrated != 5 || res.TemplatesMigrated != 5 {
|
||||
t.Fatalf("erwartet 5 felder/5 vorlagen, habe %+v", res)
|
||||
}
|
||||
|
||||
src := newSyntheticSource()
|
||||
for _, f := range src.fields {
|
||||
fieldID := deriveUUID("metadatafield", "acme-test", f.SourceFieldID)
|
||||
var label, fieldType, defaultValue *string
|
||||
var required bool
|
||||
var ruleJSON []byte
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT label, field_type, required, default_value, validation_rule FROM metadata_fields WHERE id = $1
|
||||
`, fieldID).Scan(&label, &fieldType, &required, &defaultValue, &ruleJSON); err != nil {
|
||||
t.Fatalf("feld %q lesen: %v", f.SourceFieldID, err)
|
||||
}
|
||||
if label == nil || *label != f.Label || fieldType == nil || *fieldType != f.Type || required != f.Required {
|
||||
t.Fatalf("feld %q: definition weicht von quelle ab (label=%v typ=%v pflicht=%v)", f.SourceFieldID, label, fieldType, required)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tpl := range src.templates {
|
||||
templateID := deriveUUID("documenttype", "acme-test", tpl.SourceTemplateID)
|
||||
var name string
|
||||
var retentionClass *string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT name, retention_class FROM document_types WHERE id = $1
|
||||
`, templateID).Scan(&name, &retentionClass); err != nil {
|
||||
t.Fatalf("vorlage %q lesen: %v", tpl.SourceTemplateID, err)
|
||||
}
|
||||
if name != tpl.Name {
|
||||
t.Fatalf("vorlage %q: name weicht ab, erwartet %q habe %q", tpl.SourceTemplateID, tpl.Name, name)
|
||||
}
|
||||
if (retentionClass == nil) != (tpl.RetentionClass == nil) {
|
||||
t.Fatalf("vorlage %q: aufbewahrungsklasse weicht ab", tpl.SourceTemplateID)
|
||||
}
|
||||
|
||||
var boundFieldCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM document_type_fields WHERE document_type_id = $1`, templateID).Scan(&boundFieldCount); err != nil {
|
||||
t.Fatalf("gebundene felder fuer vorlage %q zaehlen: %v", tpl.SourceTemplateID, err)
|
||||
}
|
||||
if boundFieldCount != len(tpl.SourceFieldIDs) {
|
||||
t.Fatalf("vorlage %q: erwartet %d gebundene felder, habe %d", tpl.SourceTemplateID, len(tpl.SourceFieldIDs), boundFieldCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten prueft Akzeptanz-
|
||||
// kriterium 2 (Pruefung 2: Feldwerte einer Stichprobe migrierter Dokumente
|
||||
// gegen Quelle geprueft): bereits befuellte Feldwerte an migrierten
|
||||
// Dokumenten bleiben erhalten.
|
||||
func TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten(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.FieldValuesMigrated != 3 {
|
||||
t.Fatalf("erwartet 3 feldwerte, habe %+v", res)
|
||||
}
|
||||
|
||||
for _, v := range newSyntheticSource().values {
|
||||
documentID := deriveUUID("document", "acme-test", v.SourceDocumentID)
|
||||
fieldID := deriveUUID("metadatafield", "acme-test", v.SourceFieldID)
|
||||
var value string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT value FROM document_metadata_values WHERE document_id = $1 AND field_id = $2
|
||||
`, documentID, fieldID).Scan(&value); err != nil {
|
||||
t.Fatalf("feldwert fuer dokument %q/feld %q lesen: %v", v.SourceDocumentID, v.SourceFieldID, err)
|
||||
}
|
||||
if value != v.Value {
|
||||
t.Fatalf("feldwert weicht von quelle ab: erwartet %q habe %q", v.Value, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_AkzeptanzkriteriumWiederholbar prueft, dass ein erneuter
|
||||
// Lauf gegen dieselbe Quelle keine Duplikate erzeugt (gleiches Idempotenz-
|
||||
// Muster wie DOK-01).
|
||||
func TestMigrateAll_AkzeptanzkriteriumWiederholbar(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.CustomFieldsMigrated != 0 || res2.TemplatesMigrated != 0 || res2.FieldValuesMigrated != 0 {
|
||||
t.Fatalf("zweiter lauf haette nichts neu migrieren duerfen, habe %+v", res2)
|
||||
}
|
||||
|
||||
var fieldCount, templateCount, valueCount int
|
||||
_ = pool.QueryRow(ctx, `SELECT count(*) FROM metadata_fields`).Scan(&fieldCount)
|
||||
_ = pool.QueryRow(ctx, `SELECT count(*) FROM document_types`).Scan(&templateCount)
|
||||
_ = pool.QueryRow(ctx, `SELECT count(*) FROM document_metadata_values`).Scan(&valueCount)
|
||||
if fieldCount != 5 || templateCount != 5 || valueCount != 3 {
|
||||
t.Fatalf("erneuter lauf hat duplikate erzeugt: %d felder/%d vorlagen/%d werte", fieldCount, templateCount, valueCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrationmetadata
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// deriveUUID ist identisch zu migrationdocument.deriveUUID (bewusst dupliziert,
|
||||
// nicht importiert): Namespace "document" muss hier dieselben Ziel-IDs treffen
|
||||
// wie DOK-01, damit Feldwerte an den DORT bereits migrierten Dokumenten
|
||||
// verankert werden koennen, ohne eine zusaetzliche Mapping-Tabelle.
|
||||
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])
|
||||
}
|
||||
Reference in new issue
Block a user