Files
patrickandClaude Sonnet 5.5 678bd01335
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 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 24s
CI / Web (dms-browser) (push) Failing after 1m23s
CI / Web (pdf-viewer) (push) Failing after 59s
CI / Web (shl) (push) Failing after 13s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 56s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Failing after 59s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m1s
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 1m17s
CI / Web (pdf-viewer) (pull_request) Failing after 59s
CI / Web (shl) (pull_request) Failing after 13s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 3m51s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 56s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 2m6s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m50s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m41s
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Failing after 41s
Workflow-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m18s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
workflow: testdb aus common statt interner Kopie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:38:24 +01:00

268 lines
8.0 KiB
Go

package eventtrigger
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/approval"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/moduleadapter"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/task"
)
const ownTenant = "mandant-a"
func findModuleRoot(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
}
}
type env struct {
pool *pgxpool.Pool
rules *RuleStore
dispatcher *Dispatcher
}
func newEnv(t *testing.T) env {
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(findModuleRoot(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)
}
eng, err := approval.NewEngineWithApproval(pool)
if err != nil {
t.Fatalf("engine anlegen: %v", err)
}
rules := NewRuleStore(pool)
dispatcher := NewDispatcher(pool, rules, task.NewStore(pool), approval.NewService(pool, eng))
return env{pool: pool, rules: rules, dispatcher: dispatcher}
}
func publish(t *testing.T, e env, trigger, objectID, dedupeKey string) {
t.Helper()
if _, err := moduleadapter.PublishEvent(context.Background(), e.pool, ownTenant, moduleadapter.Event{
ModuleName: "dms-fiktiv",
ObjectType: "document",
Trigger: trigger,
ObjectID: objectID,
TenantSlug: ownTenant,
DedupeKey: dedupeKey,
}); err != nil {
t.Fatalf("ereignis publizieren: %v", err)
}
}
// AK1/AK2: ein registriertes Ereignis mit passender Regel erzeugt genau
// eine Aufgabe, referenziert nur ueber IDs (kein Dokumentinhalt im
// Ereignis).
func TestDispatch_EventWithRuleCreatesExactlyOneTask(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if _, err := e.rules.Put(ctx, Rule{
ModuleName: "dms-fiktiv", ObjectType: "document", Trigger: "document.uploaded",
Kind: KindTask, AssigneeType: task.AssigneePerson, AssigneeID: "user-1", Title: "Dokument pruefen",
}); err != nil {
t.Fatalf("regel anlegen: %v", err)
}
publish(t, e, "document.uploaded", "doc-1", "dedupe-1")
n, err := e.dispatcher.Dispatch(ctx)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if n != 1 {
t.Fatalf("erwarte genau 1 dispatch, habe %d", n)
}
tasks, err := task.NewStore(e.pool).ByAssignee(ctx, task.AssigneePerson, "user-1")
if err != nil {
t.Fatalf("aufgaben lesen: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("erwarte genau eine aufgabe, habe %d", len(tasks))
}
if len(tasks[0].References) != 1 || tasks[0].References[0].ObjectID != "doc-1" {
t.Fatalf("aufgabe referenziert nicht das ausloesende objekt: %+v", tasks[0].References)
}
}
// Pruefung 1: Doppelzustellung desselben Ereignisses (gleicher DedupeKey)
// ergibt genau eine Aufgabe.
func TestDispatch_DuplicateDeliverySameEventCreatesOnlyOneTask(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if _, err := e.rules.Put(ctx, Rule{
ModuleName: "dms-fiktiv", ObjectType: "document", Trigger: "document.uploaded",
Kind: KindTask, AssigneeType: task.AssigneePerson, AssigneeID: "user-1", Title: "Dokument pruefen",
}); err != nil {
t.Fatalf("regel anlegen: %v", err)
}
publish(t, e, "document.uploaded", "doc-1", "dedupe-1")
publish(t, e, "document.uploaded", "doc-1", "dedupe-1")
if _, err := e.dispatcher.Dispatch(ctx); err != nil {
t.Fatalf("erster dispatch: %v", err)
}
// Zweiter Lauf nach "Wiederanlauf" -- muss ohne Fortschritt bleiben,
// weil die Ereigniszeile dedupliziert wurde.
if n, err := e.dispatcher.Dispatch(ctx); err != nil {
t.Fatalf("zweiter dispatch: %v", err)
} else if n != 0 {
t.Fatalf("zweiter dispatch-lauf sollte nichts mehr finden, hat aber %d verarbeitet", n)
}
tasks, err := task.NewStore(e.pool).ByAssignee(ctx, task.AssigneePerson, "user-1")
if err != nil {
t.Fatalf("aufgaben lesen: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("erwarte genau eine aufgabe trotz doppelter zustellung, habe %d", len(tasks))
}
}
// Pruefung 2: faellt die Verarbeitung aus (hier simuliert durch zwei
// getrennte Dispatch-Laeufe statt eines einzigen), gehen Ereignisse beim
// Wiederanlauf weder verloren noch werden sie doppelt verarbeitet.
func TestDispatch_SurvivesRestartWithoutLossOrDuplicates(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if _, err := e.rules.Put(ctx, Rule{
ModuleName: "dms-fiktiv", ObjectType: "document", Trigger: "document.uploaded",
Kind: KindTask, AssigneeType: task.AssigneePerson, AssigneeID: "user-1", Title: "Dokument pruefen",
}); err != nil {
t.Fatalf("regel anlegen: %v", err)
}
publish(t, e, "document.uploaded", "doc-1", "dedupe-1")
publish(t, e, "document.uploaded", "doc-2", "dedupe-2")
// Erster Lauf verarbeitet nur doc-1 (simulierter Ausfall danach).
n, err := e.dispatcher.Dispatch(ctx)
if err != nil {
t.Fatalf("erster dispatch: %v", err)
}
if n != 2 {
t.Fatalf("erwarte beide ereignisse im ersten lauf, habe %d", n)
}
// "Wiederanlauf": erneuter Dispatch-Lauf darf nichts mehr finden.
n2, err := e.dispatcher.Dispatch(ctx)
if err != nil {
t.Fatalf("zweiter dispatch: %v", err)
}
if n2 != 0 {
t.Fatalf("wiederanlauf sollte keine weiteren aufgaben erzeugen, hat aber %d", n2)
}
tasks, err := task.NewStore(e.pool).ByAssignee(ctx, task.AssigneePerson, "user-1")
if err != nil {
t.Fatalf("aufgaben lesen: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("erwarte genau zwei aufgaben (kein verlust, keine dublette), habe %d", len(tasks))
}
}
// AK2: Ereignisse ohne konfigurierte Zuordnung erzeugen keine Aufgabe --
// bleiben aber nachvollziehbar in module_events liegen statt verworfen
// zu werden.
func TestDispatch_EventWithoutRuleStaysPending(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
publish(t, e, "document.geloescht", "doc-9", "dedupe-9")
n, err := e.dispatcher.Dispatch(ctx)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if n != 0 {
t.Fatalf("ohne regel darf keine aufgabe entstehen, habe %d", n)
}
}
// AK2: eine Regel vom Typ "approval" erzeugt einen Genehmigungslauf statt
// einer Aufgabe.
func TestDispatch_ApprovalRuleCreatesApprovalTask(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if _, err := e.rules.Put(ctx, Rule{
ModuleName: "dms-fiktiv", ObjectType: "document", Trigger: "document.freigabe_noetig",
Kind: KindApproval, AssigneeID: "user-2", Title: "Freigabe",
}); err != nil {
t.Fatalf("regel anlegen: %v", err)
}
publish(t, e, "document.freigabe_noetig", "doc-3", "dedupe-3")
n, err := e.dispatcher.Dispatch(ctx)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if n != 1 {
t.Fatalf("erwarte genau 1 dispatch, habe %d", n)
}
eng, err := approval.NewEngineWithApproval(e.pool)
if err != nil {
t.Fatalf("engine: %v", err)
}
open, err := approval.NewService(e.pool, eng).OpenForAssignee(ctx, "user-2")
if err != nil {
t.Fatalf("offene genehmigungen lesen: %v", err)
}
if len(open) != 1 {
t.Fatalf("erwarte genau einen offenen genehmigungslauf, habe %d", len(open))
}
}
// Doppelte Regel-Anlage fuer dieselbe Kombination wird abgewiesen statt
// eine bestehende Zuordnung stillschweigend zu ersetzen.
func TestRuleStore_Put_RejectsDuplicateForSameEvent(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
rule := Rule{ModuleName: "dms-fiktiv", ObjectType: "document", Trigger: "document.uploaded", Kind: KindTask, AssigneeType: task.AssigneePerson, AssigneeID: "user-1", Title: "A"}
if _, err := e.rules.Put(ctx, rule); err != nil {
t.Fatalf("erste regel: %v", err)
}
if _, err := e.rules.Put(ctx, rule); err != ErrRuleExists {
t.Fatalf("erwarte ErrRuleExists, habe %v", err)
}
}