diff --git a/docs/QA-07-PRUEFPROTOKOLL.md b/docs/QA-07-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..0a45ac89 --- /dev/null +++ b/docs/QA-07-PRUEFPROTOKOLL.md @@ -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. diff --git a/internal/notifyapi/qa07_e2e_test.go b/internal/notifyapi/qa07_e2e_test.go new file mode 100644 index 00000000..86bdee39 --- /dev/null +++ b/internal/notifyapi/qa07_e2e_test.go @@ -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) + } +} diff --git a/workflow/internal/approval/approval_qa07_test.go b/workflow/internal/approval/approval_qa07_test.go new file mode 100644 index 00000000..efea34f3 --- /dev/null +++ b/workflow/internal/approval/approval_qa07_test.go @@ -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) + } +}