Merge PR #290: QA-07: Pruefgate Workflow & Benachrichtigung
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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

This commit is contained in:
patrick committed 2026-10-19 02:15:28 +02:00
commit 49fbc27632
3 files changed
+425

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
# QA-07 — Prüfgate Workflow & Benachrichtigung
Datum: 2026-10-15
Board: DMS-Kanban, Ticket QA-07
Art: QA-Prüfgate (Prozesstests, Zeitraffer) — kein neues Feature.
## Geprüfter Code (main, zum Zeitpunkt der Prüfung)
- Workflow-Modul (`workflow/internal/...`):
- `approval` — APR-01 Freigabe-Postfach/Genehmigungsansicht (Backend)
- `delegation` — APR-04 Vertretungsmodell
- `escalation` — TSK-03 Erinnerungen & Eskalationsregeln
- `escalationnotify` — Anbindung TSK-03 an Core-Dispatcher (TSK-05)
- `routingengine` / `routingrule` — RTE-02 Routing-Engine-Ausführung
- `dualcheck` — Vier-Augen-Prinzip (Zweitbestätigung)
- Core-Modul (`internal/...`):
- `notify` — CFG-02 zentraler Dispatcher (`notification_jobs`)
- `notifyprefs` — CFG-04 Benachrichtigungs-Einstellungen, `EnqueueIfAllowed`
- `notifyapi` — CFG-05 HTTP-Wrapper für Fremdmodule (Workflow ruft darüber auf)
Alle genannten Pakete waren bei Prüfungsbeginn bereits vollständig implementiert
und mit eigenen Tests abgedeckt (u.a. QA-01-Testmuster: `TEST_TENANT_DSN`,
`TEST_ADMIN_DSN`, `testdb.Pool`, echte Migrationen, Skip ohne DSN). Diese
Prüfung baut darauf auf und ergänzt zwei gezielte Lücken.
## Arbeitsumgebung
- Host 204, Wegwerf-Checkout `/root/qa-07-work-1792096871` (main, frisch geklont
von `https://gitea.perlbach24.de/scripte/nexarch.git`).
- Postgres-Testdatenbanken über `/etc/nexarch-dev/tenant-dev.env`
(`TEST_TENANT_DSN`) und `/etc/nexarch-dev/core-dev.env` (`TEST_ADMIN_DSN`) —
echte Postgres-Instanzen, kein Mocking der Datenbank.
- Go 1.26.5.
## Prozesspfade — Nachweis (Akzeptanzkriterium 1)
| Pfad | Nachweis (Testfunktion, Paket) | Ergebnis |
|---|---|---|
| Ablehnung (Decide → rejected) | `TestDecide_ReasonIsRecorded`, `approval` | PASS |
| Vertretung (Vertretung übernimmt Aufgabe) | `TestCreate_WithActiveDelegation_AssignsToSubstitute`, `approval` | PASS |
| Vertretung endet automatisch | `TestActiveSubstituteFor_EndsAutomatically`, `delegation` | PASS |
| Missbrauch: Selbst-Vertretung | `TestCreate_SelfSubstitutionAbuseIsRejectedAtRuleCreation`, `approval` | PASS |
| Eskalation bei Fristablauf | `TestProcessDue_EscalatesPastDueTask`, `escalation` | PASS |
| Erinnerung vor Fristablauf (Zeitraffer) | `TestProcessDue_ReminderFiresOnlyAtConfiguredOffset`, `escalation` | PASS |
| Erledigte Aufgabe eskaliert nicht | `TestProcessDue_CompletedTaskNeverEscalates`, `escalation` | PASS |
| Eskalation löst genau eine Benachrichtigung aus | `TestRunAt_DispatchesEscalationWithCorrectEventType`, `escalationnotify` | PASS |
Alle vier im Ticket genannten Pfade (Ablehnung, Vertretung, Eskalation,
Fristablauf) sind damit mindestens einmal real gegen Postgres durchlaufen.
AC1 erfüllt.
## Prüfung 1 — Zeitraffer-Test über simulierte Fristen
Ziel: Fristlogik ohne echtes Warten prüfbar, über austauschbare Zeitquelle.
Befund: `escalation.Store.ProcessDue(ctx, now time.Time)` nimmt den
Referenzzeitpunkt bereits als Parameter entgegen (keine `time.Now()`-Aufrufe
im Kernpfad) — austauschbare Zeitquelle ist strukturell bereits vorhanden.
`escalationnotify.RunAt(..., now time.Time)` reicht denselben Zeitpunkt
durch.
Zielwert: Erinnerung bei konfiguriertem Vorlauf löst aus, Eskalation bei
Fälligkeit noch nicht — und umgekehrt, rein über simulierte `now`-Werte,
ohne reale Wartezeit.
Test: `TestProcessDue_ReminderFiresOnlyAtConfiguredOffset` (`escalation`) —
Fälligkeit real +10 Tage, simuliertes `now` = +8 Tage (Erinnerung bei
Vorlauf 3 Tage fällig, Eskalation noch nicht).
Ergebnis: PASS — Erinnerung ausgelöst, Eskalation korrekt noch nicht
ausgelöst.
```
=== RUN TestProcessDue_ReminderFiresOnlyAtConfiguredOffset
--- PASS: TestProcessDue_ReminderFiresOnlyAtConfiguredOffset
```
Zusätzlich: `TestProcessDue_Idempotent` belegt, dass ein wiederholter
Joblauf mit demselben `now` keine zweite Eskalation erzeugt (Zeitraffer +
Idempotenz kombiniert).
## Prüfung 2 — Parallelentscheidungen erzeugen keinen Fehlzustand
Ziel: Zwei Vertreter entscheiden gleichzeitig über dieselbe Freigabe — kein
inkonsistenter Endzustand, Race-Test gegen echte Postgres-Testdatenbank.
Bereits vorhanden: `TestDecide_ConcurrentDecisionsProduceNoInconsistentState`
(`approval`) — zwei parallele `Decide`-Aufrufe desselben Zuständigen
(`approved` vs. `rejected`), über `statemachine.Engine` serialisiert
(`ErrConcurrentTransition` → `ErrAlreadyDecided`).
**Ergänzt (QA-07):** `TestDecide_ConcurrentSubstitutesOnDelegatedTask`
(`workflow/internal/approval/approval_qa07_test.go`) — realisiert das
Ticket-Szenario direkt mit Vertretung (APR-04): Aufgabe wird per
Delegationsregel an `bob` umgeleitet, danach entscheiden drei Akteure
parallel über dieselbe Aufgabe: `bob` zweimal (zwei Geräte/Tabs derselben
Vertretung) und `mallory` (nicht eingetragene zweite Person) einmal.
Befund/Designgrenze: Das System kennt je Aufgabe genau einen Zuständigen
(`assignee`); zwei *gleichzeitig gültige* Vertretungen für dieselbe Aufgabe
sind durch das Datenmodell ausgeschlossen (`ActiveSubstituteFor` liefert
deterministisch genau eine Vertretung, siehe
`TestActiveSubstituteFor_OverlappingRulesResolveToMostRecentlyCreated`).
Das real relevante Risiko ist daher die gleichzeitige Mehrfachentscheidung
durch denselben (einzigen) Zuständigen bzw. ein unbefugter Parallelversuch
— genau das deckt der ergänzte Test ab.
Zielwert: genau 1 Erfolg, genau 1 `ErrAlreadyDecided` (zweiter Versuch von
`bob`), genau 1 `ErrForbidden` (`mallory`), Endstatus konsistent
(`approved` oder `rejected`, nie beides/keins), `DecidedBy == "bob"`.
Ergebnis: PASS, real gegen Postgres (`TEST_TENANT_DSN`), 3 nebenläufige
Goroutinen:
```
=== RUN TestDecide_ConcurrentSubstitutesOnDelegatedTask
--- PASS: TestDecide_ConcurrentSubstitutesOnDelegatedTask (0.36s)
```
Kein Fund (keine Lücke): Die Engine serialisiert korrekt über
`ErrConcurrentTransition`, die Vertretungszuordnung bleibt eindeutig.
## Prüfung 3 — Zustellprotokoll gegen erwartete Ereignisse abgeglichen
Ziel: jedes erwartete Ereignis erscheint genau einmal im Zustellprotokoll
(`notification_jobs`, Core CFG-02), kein Duplikat, keine Lücke.
Bereits vorhanden (Mock-Dispatcher-Ebene):
`TestRunAt_RepeatedRunDoesNotDoubleDispatch` (`escalationnotify`) — belegt,
dass ein wiederholter Joblauf keinen zweiten Dispatcher-Aufruf erzeugt
(gegen `httptest`-Fake, nicht gegen das echte Zustellprotokoll).
**Ergänzt (QA-07):**
`TestE2E_DeliveryLogMatchesExpectedWorkflowEvents`
(`internal/notifyapi/qa07_e2e_test.go`) — echter End-zu-Ende-Pfad: echter
`notify.Dispatcher` + echter `notifyprefs.Store` + echter
`notifyapi`-HTTP-Server (derselbe Pfad, den Workflow über
`notifyclient`/CFG-05 nutzt), gegen echte Postgres-Testdatenbank
(`TEST_ADMIN_DSN`). Simuliert 5 Ereignisse aus den vier Prozesspfaden
(Ablehnung, Vertretung, Eskalation, Fristablauf-Erinnerung) parallel, davon
eines mit deaktivierter Präferenz (CFG-04, `EnqueueIfAllowed`). Liest danach
`notification_jobs` direkt aus der DB und gleicht jeden erwarteten Empfänger
gegen genau 1 Protokolleintrag ab; der deaktivierte Kanal darf 0 Einträge
erzeugen.
Zielwert: 4 aktivierte Ereignisse → je genau 1 Zeile in `notification_jobs`;
1 deaktiviertes Ereignis → 0 Zeilen.
Ergebnis: PASS.
```
=== RUN TestE2E_DeliveryLogMatchesExpectedWorkflowEvents
--- PASS: TestE2E_DeliveryLogMatchesExpectedWorkflowEvents (0.19s)
```
Kein Fund (keine Lücke): Dispatcher und Präferenzfilter erzeugen weder
Duplikate noch Verluste; der Service-Token-geschützte HTTP-Pfad (CFG-05)
verhält sich identisch zum direkten `EnqueueIfAllowed`-Aufruf.
## Testläufe (vollständig)
```
$ cd workflow && go test ./...
ok .../workflow-app
ok .../internal/approval 9.732s
ok .../internal/approvalapi 4.354s
ok .../internal/backchannel 8.814s
ok .../internal/backchannelapi 0.721s
ok .../internal/calendar 3.386s
ok .../internal/caseobj 6.576s
ok .../internal/caseoverview 5.064s
ok .../internal/caseoverviewapi 2.496s
ok .../internal/delegation (cached)
ok .../internal/dualcheck (cached)
ok .../internal/escalation (cached)
ok .../internal/escalationnotify (cached)
ok .../internal/eventtrigger 4.141s
ok .../internal/migrationworkflow 0.697s
ok .../internal/moduleadapter 7.076s
ok .../internal/notifyclient 0.008s
ok .../internal/processdef 2.756s
ok .../internal/reminder 3.533s
ok .../internal/routingengine (cached)
ok .../internal/routingrule 2.225s
ok .../internal/routingruleapi 4.951s
ok .../internal/routingsim 2.554s
ok .../internal/routingsuggestclient 0.006s
ok .../internal/statemachine 31.843s
ok .../internal/stepretry 1.831s
ok .../internal/task 2.217s
$ go test ./internal/notify/... ./internal/notifyprefs/... ./internal/notifyapi/... -v
--- PASS: TestDispatcher_EnqueueAndProcess
--- PASS: TestProcessDue_RetriesThenGivesUpAfterMaxAttempts
--- PASS: TestQueue_SurvivesRestartWithoutMessageLoss
--- PASS: TestProcessDue_ConcurrentDispatchBothDelivered
--- PASS: TestHandlerSetAndListMine
--- PASS: TestHandlerListMine_NeverShowsOtherUsersPreferences
--- PASS: TestHandlerListTenantOverview
--- PASS: TestIsEnabled_DefaultsToTrueWithoutPreference
--- PASS: TestSetAndIsEnabled
--- PASS: TestSet_DoesNotAffectOtherUser
--- PASS: TestEnqueueIfAllowed_SkipsWhenDisabled
--- PASS: TestEnqueueIfAllowed_EnqueuesWhenEnabled
--- PASS: TestListForTenant_ShowsAllUsers
--- PASS: TestEnqueueHandler_MissingTokenReturns401
--- PASS: TestEnqueueHandler_MatchesDirectEnqueueIfAllowed
--- PASS: TestEnqueueHandler_RealForeignModuleClient
--- PASS: TestE2E_DeliveryLogMatchesExpectedWorkflowEvents
```
`go build ./...` und `go vet ./...` beider Module fehlerfrei.
`go test -race` konnte in dieser nicht-interaktiven SSH-Sitzung nicht
ausgeführt werden (`gcc`/cgo nicht im PATH des nicht-interaktiven Shells
verfügbar, laut Arbeitsumgebungsvorgabe keine Toolchain-Installationen auf
Host 204). Die Nebenläufigkeitsprüfungen (Prüfung 2, Prüfung 3) laufen
stattdessen mit echten nebenläufigen Goroutinen gegen die echte
Postgres-Testdatenbank — der eigentlich geforderte Nachweis (DB-Race, nicht
Go-Memory-Race) ist damit erbracht.
## Ergebnis
Alle drei geforderten Prüfungen bestanden. Alle vier Prozesspfade
durchlaufen. Keine Produktionslücke gefunden, die eine Korrektur
erforderte — Dispatcher, Präferenzfilter, Eskalations-Engine und
Zustand-Serialisierung verhalten sich bereits korrekt. Ergänzt wurden zwei
gezielte End-zu-Ende-/Race-Tests, die die bestehende Unit-Testdecke um die
im Ticket explizit geforderten Szenarien (Vertretungsrace, echtes
Zustellprotokoll statt Mock) erweitern:
- `workflow/internal/approval/approval_qa07_test.go`
- `internal/notifyapi/qa07_e2e_test.go`
Akzeptanzkriterien 1–3 sind damit nachgewiesen erfüllt.
+95
View File
@@ -0,0 +1,95 @@
package notifyapi
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// QA-07 Pruefung 3: Zustellprotokoll (notification_jobs, Core CFG-02) wird
// gegen die aus den simulierten Workflow-Prozesspfaden (Ablehnung, Eskalation,
// Fristablauf) erwarteten Ereignisse abgeglichen -- jedes erwartete Ereignis
// erscheint genau einmal, kein Duplikat, keine Luecke. Treibt denselben
// HTTP-Pfad an, den workflow/internal/escalationnotify.DispatchDue und
// Aehnliches live gegen Core CFG-05 nutzen.
func TestE2E_DeliveryLogMatchesExpectedWorkflowEvents(t *testing.T) {
prefs, dispatcher, pool := setupTest(t)
ctx := context.Background()
mux := http.NewServeMux()
Mount(mux, prefs, dispatcher, testToken)
server := httptest.NewServer(mux)
defer server.Close()
tenant := "acme"
if err := prefs.Set(ctx, tenant, "cfg05_qa07_muted", "workflow.task.escalated", "email", false); err != nil {
t.Fatalf("praeferenz deaktivieren: %v", err)
}
type expectedEvent struct {
userID string
eventType string
recipient string
}
events := []expectedEvent{
{userID: "cfg05_qa07_alice", eventType: "workflow.approval.rejected", recipient: "cfg05_qa07_alice@acme.example"},
{userID: "cfg05_qa07_bob", eventType: "workflow.approval.delegated", recipient: "cfg05_qa07_bob@acme.example"},
{userID: "cfg05_qa07_carol", eventType: "workflow.task.escalated", recipient: "cfg05_qa07_carol@acme.example"},
{userID: "cfg05_qa07_dave", eventType: "workflow.task.reminder_due", recipient: "cfg05_qa07_dave@acme.example"},
{userID: "cfg05_qa07_muted", eventType: "workflow.task.escalated", recipient: "cfg05_qa07_muted@acme.example"},
}
var wg sync.WaitGroup
for _, ev := range events {
wg.Add(1)
go func(ev expectedEvent) {
defer wg.Done()
resp := post(t, server, testToken, enqueueRequest{
TenantSlug: tenant, UserID: ev.userID, EventType: ev.eventType,
Channel: "email", Recipient: ev.recipient,
Payload: map[string]any{"task_id": "qa07-" + ev.userID},
})
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("enqueue fuer %s/%s: status = %d", ev.userID, ev.eventType, resp.StatusCode)
}
}(ev)
}
wg.Wait()
rows, err := pool.Query(ctx, `
SELECT recipient, payload->>'task_id'
FROM notification_jobs
WHERE recipient LIKE 'cfg05\_qa07\_%' ESCAPE '\'
`)
if err != nil {
t.Fatalf("zustellprotokoll lesen: %v", err)
}
defer rows.Close()
seen := map[string]int{}
for rows.Next() {
var recipient, taskID string
if err := rows.Scan(&recipient, &taskID); err != nil {
t.Fatalf("zeile lesen: %v", err)
}
seen[recipient]++
}
if err := rows.Err(); err != nil {
t.Fatalf("iterieren: %v", err)
}
for _, ev := range events {
if ev.userID == "cfg05_qa07_muted" {
continue
}
if seen[ev.recipient] != 1 {
t.Fatalf("empfaenger %s: %d eintraege im zustellprotokoll, want genau 1 (kein duplikat, keine luecke)", ev.recipient, seen[ev.recipient])
}
}
if n := seen["cfg05_qa07_muted@acme.example"]; n != 0 {
t.Fatalf("deaktivierter kanal hat trotzdem %d eintraege im zustellprotokoll erzeugt, want 0", n)
}
}
@@ -0,0 +1,95 @@
package approval
import (
"context"
"errors"
"sync"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/delegation"
)
// QA-07 Pruefung 2: Parallelentscheidung zweier Vertreter ueber dieselbe
// Freigabe erzeugt keinen Fehlzustand. Die Aufgabe wird per Vertretung
// (APR-04) an bob umgeleitet; bob entscheidet zweimal parallel (z.B. zwei
// Tabs/Geraete derselben Vertretung), waehrend ein zweiter, nicht
// eingetragener Vertreter (mallory) parallel ebenfalls zu entscheiden
// versucht. Erwartung: genau eine Entscheidung gewinnt, alle anderen
// bekommen einen definierten Fehler, der Endzustand ist konsistent.
func TestDecide_ConcurrentSubstitutesOnDelegatedTask(t *testing.T) {
svc := newTestService(t)
delegationStore := delegation.NewStore(svc.pool)
svc.WithDelegation(delegationStore)
ctx := context.Background()
now := time.Now().UTC()
if _, err := delegationStore.CreateRule(ctx, "alice", "bob", now.Add(-time.Hour), now.Add(time.Hour)); err != nil {
t.Fatalf("createrule: %v", err)
}
task, err := svc.Create(ctx, "alice", "dms:document:qa07", "system")
if err != nil {
t.Fatalf("create: %v", err)
}
if task.Assignee != "bob" {
t.Fatalf("erwartet zuweisung an bob, erhalten %q", task.Assignee)
}
type attempt struct {
actor string
decision Decision
}
attempts := []attempt{
{actor: "bob", decision: Approved},
{actor: "bob", decision: Rejected},
{actor: "mallory", decision: Approved},
}
var wg sync.WaitGroup
results := make(chan error, len(attempts))
for _, a := range attempts {
wg.Add(1)
go func(a attempt) {
defer wg.Done()
_, err := svc.Decide(ctx, task.ID, a.actor, a.decision, "parallele vertretung")
results <- err
}(a)
}
wg.Wait()
close(results)
var successes, alreadyDecided, forbidden int
for err := range results {
switch {
case err == nil:
successes++
case errors.Is(err, ErrAlreadyDecided):
alreadyDecided++
case errors.Is(err, ErrForbidden):
forbidden++
default:
t.Fatalf("unerwarteter fehler: %v", err)
}
}
if successes != 1 {
t.Fatalf("successes=%d, want genau 1", successes)
}
if alreadyDecided != 1 {
t.Fatalf("alreadyDecided=%d, want genau 1 (bobs zweiter versuch)", alreadyDecided)
}
if forbidden != 1 {
t.Fatalf("forbidden=%d, want genau 1 (mallory ist nicht die eingetragene vertretung)", forbidden)
}
got, err := svc.Get(ctx, task.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != "approved" && got.Status != "rejected" {
t.Fatalf("status = %q, want approved oder rejected, kein inkonsistenter zustand", got.Status)
}
if got.DecidedBy != "bob" {
t.Fatalf("decidedBy = %q, want bob (einzig zustaendige vertretung)", got.DecidedBy)
}
}