Files
nexarch/workflow/internal/gates/engine/qa02_test.go
T
patrickandClaude Sonnet 5.5 5b8c20777d refactor: Pruefgate-Pakete nach Funktion benannt, migration* gruppiert
- dms/ai/workflow: qa0x-Pakete nach internal/gates/<thema> verschoben, Paketname = Thema
- internal/migration{acl,audit,...} -> internal/migration/{acl,audit,...}
- Importpfade, Gate-Skripte und zwei relative Migrationspfade angepasst
- keine Logikaenderung; mail-Gates bewusst unveraendert (pfadabhaengig)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:44:21 +01:00

335 lines
10 KiB
Go

// Package engine ist das Pruefgate fuer QA-02: Zustandsautomat (ENG-01),
// Prozessdefinitions-Format (ENG-03), Modul-Adapter-Schnittstelle (ENG-02)
// und Fehlerbehandlung/Retry (ENG-05) werden hier nicht isoliert, sondern
// im Zusammenspiel gegen eine echte, frisch migrierte Testdatenbank
// gefahren.
package engine
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/moduleadapter"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/processdef"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/statemachine"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/stepretry"
"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 newPool(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 := 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(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return pool
}
// rechnungsfreigabeJSON ist eine rein fiktive Prozessdefinition (ENG-03
// Format) mit 6 Zustaenden und 7 Uebergaengen, darunter ein Zyklus
// (nacharbeit<->pruefung) und zwei verschiedene Abbruchpfade — genug
// Struktur, um jeden Uebergang ueber mehrere Instanzen hinweg mindestens
// einmal real zu durchlaufen (Akzeptanzkriterium 1).
const rechnungsfreigabeJSON = `{
"name": "rechnungsfreigabe-qa02",
"states": ["antrag", "pruefung", "nacharbeit", "freigegeben", "abgelehnt", "storniert"],
"initial": "antrag",
"transitions": [
{"from": "antrag", "to": "pruefung"},
{"from": "pruefung", "to": "freigegeben"},
{"from": "pruefung", "to": "nacharbeit"},
{"from": "nacharbeit", "to": "pruefung"},
{"from": "pruefung", "to": "abgelehnt"},
{"from": "antrag", "to": "storniert"},
{"from": "nacharbeit", "to": "storniert"}
]
}`
type transitionKey struct{ from, to string }
// AK1 + Pruefung 1: alle sieben deklarierten Uebergaenge werden ueber vier
// Instanzen hinweg real durchlaufen, und ein unzulaessiger Uebergang wird
// abgewiesen.
func TestZustandsautomat_AlleUebergaengeDurchlaufenUndUnzulaessigerUebergangAbgewiesen(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
registry := processdef.NewRegistry(pool)
def, _, err := registry.Load(ctx, []byte(rechnungsfreigabeJSON))
if err != nil {
t.Fatalf("prozessdefinition laden: %v", err)
}
engine, err := registry.BuildEngine(ctx)
if err != nil {
t.Fatalf("engine bauen: %v", err)
}
visited := map[transitionKey]bool{}
walk := func(actor string, steps ...string) string {
inst, err := registry.Start(ctx, engine, def.Name, actor)
if err != nil {
t.Fatalf("instanz starten: %v", err)
}
from := inst.State
for _, to := range steps {
if err := engine.Transition(ctx, inst.ID, to, actor, "qa02"); err != nil {
t.Fatalf("uebergang %s->%s: %v", from, to, err)
}
visited[transitionKey{from, to}] = true
from = to
}
return inst.ID
}
walk("alice", "pruefung", "freigegeben")
walk("bob", "pruefung", "nacharbeit", "pruefung", "abgelehnt")
walk("carol", "storniert")
walk("dave", "pruefung", "nacharbeit", "storniert")
for _, tr := range def.Transitions {
if !visited[transitionKey{tr.From, tr.To}] {
t.Errorf("uebergang %s->%s wurde in keiner Instanz durchlaufen", tr.From, tr.To)
}
}
// Pruefung 1: unzulaessiger Uebergang (antrag -> freigegeben ist nicht
// deklariert) wird abgewiesen, der Zustand bleibt unveraendert.
rejectedID := walk("erin")
if err := engine.Transition(ctx, rejectedID, "freigegeben", "erin", "verbotener versuch"); !errors.Is(err, statemachine.ErrIllegalTransition) {
t.Fatalf("erwartet ErrIllegalTransition, habe %v", err)
}
got, err := engine.Get(ctx, rejectedID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.State != "antrag" {
t.Fatalf("zustand nach abgewiesenem uebergang = %q, will unveraendert antrag", got.State)
}
}
// AK2 + Pruefung 2: zwei fiktive Modul-Adapter werden gleichzeitig
// registriert und unabhaengig voneinander geprueft.
func TestAdapterRegistrierung_ZweiFiktiveModuleGleichzeitig(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
type result struct {
reg moduleadapter.Registration
err error
}
results := make([]result, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
reg, err := moduleadapter.Register(ctx, pool, "fiktivmodul-a", "rechnung", []string{"rechnung.eingereicht"})
results[0] = result{reg, err}
}()
go func() {
defer wg.Done()
reg, err := moduleadapter.Register(ctx, pool, "fiktivmodul-b", "urlaubsantrag", []string{"antrag.gestellt"})
results[1] = result{reg, err}
}()
wg.Wait()
for i, r := range results {
if r.err != nil {
t.Fatalf("registrierung %d: %v", i, r.err)
}
if r.reg.ActiveVersion != 1 {
t.Fatalf("registrierung %d: version = %d, will 1", i, r.reg.ActiveVersion)
}
}
regs, err := moduleadapter.ListRegistrations(ctx, pool)
if err != nil {
t.Fatalf("auflisten: %v", err)
}
if len(regs) != 2 {
t.Fatalf("erwarte 2 registrierungen, habe %d: %+v", len(regs), regs)
}
triggersA, err := moduleadapter.TriggersFor(ctx, pool, "fiktivmodul-a", "rechnung")
if err != nil {
t.Fatalf("trigger fiktivmodul-a: %v", err)
}
if len(triggersA) != 1 || triggersA[0] != "rechnung.eingereicht" {
t.Fatalf("trigger fiktivmodul-a = %v, will [rechnung.eingereicht]", triggersA)
}
triggersB, err := moduleadapter.TriggersFor(ctx, pool, "fiktivmodul-b", "urlaubsantrag")
if err != nil {
t.Fatalf("trigger fiktivmodul-b: %v", err)
}
if len(triggersB) != 1 || triggersB[0] != "antrag.gestellt" {
t.Fatalf("trigger fiktivmodul-b = %v, will [antrag.gestellt]", triggersB)
}
}
// AK3 + Pruefung 3: ein simulierter Fehlerfall loest Retry mit Backoff aus,
// gebunden an eine echte Workflow-Instanz; nach erschoepften Versuchen
// landet der Schritt im Dead-Letter und ist manuell reaktivierbar.
func TestFehlerbehandlungUndRetry_SimulierterFehlerLoestRetryAus(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
def := statemachine.ProcessDefinition{
Name: "benachrichtigung-qa02",
States: []string{"offen", "erledigt"},
Initial: "offen",
Transitions: []statemachine.Transition{
{From: "offen", To: "erledigt"},
},
}
engine, err := statemachine.NewEngine(pool, def)
if err != nil {
t.Fatalf("engine anlegen: %v", err)
}
inst, err := engine.Start(ctx, "benachrichtigung-qa02", "qa02")
if err != nil {
t.Fatalf("instanz starten: %v", err)
}
store := stepretry.NewStore(pool)
calls := 0
fn := func(context.Context) error {
calls++
if calls < 3 {
return errors.New("simulierter dienstausfall")
}
return nil
}
step, err := store.Schedule(ctx, inst.ID, "benachrichtigung-versand", 5)
if err != nil {
t.Fatalf("schedule: %v", err)
}
if err := stepretry.Execute(ctx, store, step, fn); err == nil {
t.Fatal("erster versuch haette fehlschlagen sollen")
}
got, err := store.Get(ctx, step.ID)
if err != nil {
t.Fatalf("get nach versuch 1: %v", err)
}
if got.Status != stepretry.StatusPending || got.Attempts != 1 || !got.NextAttemptAt.After(time.Now()) {
t.Fatalf("nach fehlgeschlagenem versuch: %+v", got)
}
// Zeitraffer statt echtem Warten: ClaimDue mit einem weit in der Zukunft
// liegenden "jetzt" macht den Schritt sofort faellig.
due, err := store.ClaimDue(ctx, time.Now().Add(10*time.Minute), 10)
if err != nil {
t.Fatalf("claim due: %v", err)
}
var claimed *stepretry.Step
for i := range due {
if due[i].ID == step.ID {
claimed = &due[i]
}
}
if claimed == nil {
t.Fatal("schritt haette nach zeitraffer faellig sein muessen")
}
if err := stepretry.Execute(ctx, store, *claimed, fn); err == nil {
t.Fatal("zweiter versuch haette ebenfalls fehlschlagen sollen")
}
if err := stepretry.Execute(ctx, store, stepretry.Step{ID: step.ID}, fn); err != nil {
t.Fatalf("dritter versuch (sollte gelingen): %v", err)
}
got, err = store.Get(ctx, step.ID)
if err != nil {
t.Fatalf("get nach erfolg: %v", err)
}
if got.Status != stepretry.StatusSucceeded {
t.Fatalf("status nach erfolg = %q, will succeeded", got.Status)
}
if calls != 3 {
t.Fatalf("fn wurde %d mal aufgerufen, will 3", calls)
}
if err := engine.Transition(ctx, inst.ID, "erledigt", "qa02", "benachrichtigt"); err != nil {
t.Fatalf("abschliessender uebergang nach erfolgreichem retry: %v", err)
}
// Dauerhaft fehlschlagender zweiter Schritt: Dead-Letter + Reaktivierung.
deadStep, err := store.Schedule(ctx, inst.ID, "erp-meldung", 2)
if err != nil {
t.Fatalf("schedule dead-letter-schritt: %v", err)
}
alwaysFail := func(context.Context) error { return errors.New("erp dauerhaft nicht erreichbar") }
for i := 0; i < 2; i++ {
cur, err := store.Get(ctx, deadStep.ID)
if err != nil {
t.Fatalf("get vor versuch %d: %v", i+1, err)
}
if err := stepretry.Execute(ctx, store, cur, alwaysFail); err == nil {
t.Fatalf("versuch %d haette fehlschlagen muessen", i+1)
}
}
cur, err := store.Get(ctx, deadStep.ID)
if err != nil {
t.Fatalf("get nach erschoepften versuchen: %v", err)
}
if cur.Status != stepretry.StatusDeadLetter {
t.Fatalf("status = %q, will dead_letter", cur.Status)
}
dead, err := store.ListDeadLetter(ctx)
if err != nil {
t.Fatalf("list dead letter: %v", err)
}
found := false
for _, d := range dead {
if d.ID == deadStep.ID {
found = true
}
}
if !found {
t.Fatal("dead-letter-schritt nicht gefunden")
}
if err := store.Reactivate(ctx, deadStep.ID); err != nil {
t.Fatalf("reactivate: %v", err)
}
cur, err = store.Get(ctx, deadStep.ID)
if err != nil {
t.Fatalf("get nach reactivate: %v", err)
}
if cur.Status != stepretry.StatusPending || cur.Attempts != 0 {
t.Fatalf("nach reactivate: %+v, will pending/attempts=0", cur)
}
}