Files
nexarch/workflow/internal/processdef/processdef_test.go
patrickandClaude Sonnet 5 38b18b5f50
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
ENG-03: prozessdefinitions format deklarativ versionierbar
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 01:47:51 +02:00

203 lines
6.8 KiB
Go

package processdef
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb"
)
func moduleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod nicht gefunden")
}
dir = parent
}
}
func newRegistry(t *testing.T) *Registry {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt")
}
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(filepath.Join(moduleRoot(t), "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return NewRegistry(pool)
}
const validDef = `{
"name": "freigabe",
"states": ["entwurf", "geprueft", "freigegeben"],
"initial": "entwurf",
"transitions": [
{"from": "entwurf", "to": "geprueft"},
{"from": "geprueft", "to": "freigegeben"}
]
}`
// Akzeptanzkriterium 1 / Pruefung 1: gueltige Definition wird geladen, eine
// strukturell fehlerhafte Definition wird beim Laden abgewiesen statt
// unvalidiert uebernommen zu werden.
func TestLoad_ValidatesSchema(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
if _, _, err := r.Load(ctx, []byte(validDef)); err != nil {
t.Fatalf("gueltige definition abgewiesen: %v", err)
}
cases := []struct {
name string
json string
}{
{"kein name", `{"states":["a"],"initial":"a"}`},
{"startzustand unbekannt", `{"name":"x","states":["a","b"],"initial":"c"}`},
{"uebergang referenziert unbekannten zustand", `{"name":"x","states":["a","b"],"initial":"a","transitions":[{"from":"a","to":"c"}]}`},
{"kaputtes json", `{nicht valide`},
{"keine zustaende", `{"name":"x","states":[],"initial":""}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, _, err := r.Load(ctx, []byte(c.json)); !errors.Is(err, ErrInvalidDefinition) {
t.Fatalf("erwartet ErrInvalidDefinition, bekam %v", err)
}
})
}
}
// Akzeptanzkriterium 3 / Pruefung 2: ein Versionswechsel waehrend eine
// Instanz laeuft, veraendert deren Verhalten nicht rueckwirkend — die
// Instanz bleibt an ihre Startversion gebunden, inklusive eines dort
// erlaubten Uebergangs, der in der neuen Version nicht mehr existiert.
func TestVersionSwitch_DoesNotAffectRunningInstance(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
v1 := `{"name":"vertrag","states":["entwurf","aktiv"],"initial":"entwurf","transitions":[{"from":"entwurf","to":"aktiv"}]}`
if _, version, err := r.Load(ctx, []byte(v1)); err != nil || version != 1 {
t.Fatalf("v1 laden: version=%d err=%v", version, err)
}
engine, err := r.BuildEngine(ctx)
if err != nil {
t.Fatalf("engine bauen: %v", err)
}
inst, err := r.Start(ctx, engine, "vertrag", "tester")
if err != nil {
t.Fatalf("instanz starten: %v", err)
}
// Version 2 entfernt den Uebergang entwurf->aktiv wieder (nur ein
// gesonderter Endzustand), ohne Codeaenderung, rein per neuer
// Definition.
v2 := `{"name":"vertrag","states":["entwurf","abgelehnt"],"initial":"entwurf","transitions":[{"from":"entwurf","to":"abgelehnt"}]}`
if _, version, err := r.Load(ctx, []byte(v2)); err != nil || version != 2 {
t.Fatalf("v2 laden: version=%d err=%v", version, err)
}
engine2, err := r.BuildEngine(ctx)
if err != nil {
t.Fatalf("engine nach v2 neu bauen: %v", err)
}
// Die bereits laufende v1-Instanz darf weiterhin ihren v1-Uebergang
// ausfuehren, obwohl v2 jetzt aktiv ist.
if err := engine2.Transition(ctx, inst.ID, "aktiv", "tester", "abschluss"); err != nil {
t.Fatalf("v1-instanz sollte weiterhin nach v1 uebergehen koennen: %v", err)
}
// Eine NEUE Instanz bekommt dagegen v2 und kennt den v1-Uebergang
// "aktiv" gar nicht mehr.
inst2, err := r.Start(ctx, engine2, "vertrag", "tester")
if err != nil {
t.Fatalf("neue instanz starten: %v", err)
}
if err := engine2.Transition(ctx, inst2.ID, "aktiv", "tester", "sollte fehlschlagen"); err == nil {
t.Fatal("neue v2-instanz sollte den v1-only-uebergang nicht kennen")
}
if err := engine2.Transition(ctx, inst2.ID, "abgelehnt", "tester", "v2-uebergang"); err != nil {
t.Fatalf("v2-instanz sollte ihren eigenen uebergang ausfuehren koennen: %v", err)
}
}
// Pruefung 3: Rollback auf die Vorgaengerversion funktioniert, neue
// Instanzen bekommen danach wieder die aeltere Version.
func TestRollbackTo_RestoresPreviousVersionForNewInstances(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
v1 := `{"name":"urlaub","states":["beantragt","genehmigt"],"initial":"beantragt","transitions":[{"from":"beantragt","to":"genehmigt"}]}`
if _, _, err := r.Load(ctx, []byte(v1)); err != nil {
t.Fatalf("v1 laden: %v", err)
}
v2 := `{"name":"urlaub","states":["beantragt","abgelehnt"],"initial":"beantragt","transitions":[{"from":"beantragt","to":"abgelehnt"}]}`
if _, _, err := r.Load(ctx, []byte(v2)); err != nil {
t.Fatalf("v2 laden: %v", err)
}
active, err := r.ActiveVersion(ctx, "urlaub")
if err != nil || active != 2 {
t.Fatalf("aktive version vor rollback: %d %v", active, err)
}
if err := r.RollbackTo(ctx, "urlaub", 1); err != nil {
t.Fatalf("rollback: %v", err)
}
active, err = r.ActiveVersion(ctx, "urlaub")
if err != nil || active != 1 {
t.Fatalf("aktive version nach rollback: %d %v", active, err)
}
engine, err := r.BuildEngine(ctx)
if err != nil {
t.Fatalf("engine bauen: %v", err)
}
inst, err := r.Start(ctx, engine, "urlaub", "tester")
if err != nil {
t.Fatalf("instanz nach rollback starten: %v", err)
}
if err := engine.Transition(ctx, inst.ID, "genehmigt", "tester", "v1-uebergang nach rollback"); err != nil {
t.Fatalf("nach rollback sollte v1-uebergang wieder verfuegbar sein: %v", err)
}
if err := r.RollbackTo(ctx, "urlaub", 99); !errors.Is(err, ErrUnknownVersion) {
t.Fatalf("rollback auf unbekannte version: erwartet ErrUnknownVersion, bekam %v", err)
}
}
// Akzeptanzkriterium 1: mehrere Prozesse unabhaengig ladbar, Version zaehlt
// je Prozessname separat hoch.
func TestLoad_VersionsPerProcessNameIndependent(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
if _, v, err := r.Load(ctx, []byte(validDef)); err != nil || v != 1 {
t.Fatalf("erste version von freigabe: %d %v", v, err)
}
other := `{"name":"andererprozess","states":["a"],"initial":"a"}`
if _, v, err := r.Load(ctx, []byte(other)); err != nil || v != 1 {
t.Fatalf("erste version von andererprozess: %d %v", v, err)
}
if _, v, err := r.Load(ctx, []byte(validDef)); err != nil || v != 2 {
t.Fatalf("zweite version von freigabe: %d %v", v, err)
}
}