feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)

Ebene 3 (Rollen verantwortlicher/pruefer) bekommt GET /faelle
(Posteingang aller offenen Anträge des Mandanten) und GET/POST
/faelle/{id} zum Entscheiden. Eine Entscheidung friert Regelwerk-,
Katalog- und den vollständigen Werkzeugdatensatz ein, verlangt eine
Begründung bei Abweichung vom abgeleiteten Vorschlag, erlaubt bei
Genehmigung nur ein durch die Bewertung zulässiges Werkzeug und
protokolliert die Entscheidung im Audit-Log. entscheidung ist
append-only wie bewertung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-29 12:26:51 +02:00
parent e968cf9761
commit d1e80dd19f
14 changed files with 1017 additions and 92 deletions

View File

@@ -0,0 +1,216 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der
// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung
// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine
// zweite Cookie für einen weiteren Nutzer der Rolle role im selben
// Mandanten (verantwortlicher/pruefer).
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
for id := range fs.antraege {
if fs.antraege[id].AccountID == acc.ID {
antragID = id
}
}
fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role)
return antragID, fachebeneCookie
}
func TestMitarbeiterCannotAccessFaelle(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com")
resp := getWithCookie(t, s, cookie, "/faelle")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code)
}
}
func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher")
otherResp := getWithCookie(t, s, otherCookie, "/faelle")
if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String())
}
}
func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String())
}
}
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String())
}
}
func TestFallEntscheidenRejectsPruefer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for pruefer", resp.Code)
}
}
// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm
// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag
// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon
// ab und braucht eine Begründung.
func TestFallEntscheidenRequiresBegruendungOnAbweichung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"abgelehnt"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String())
}
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String())
}
}
func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String())
}
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if !strings.Contains(detail.Body.String(), "rueckfrage") {
t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String())
}
}
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
fs := newFakeStore()
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
s := newServer(t, fs)
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
if createResp.Code != http.StatusSeeOther {
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
}
var antragID string
for id := range fs.antraege {
if fs.antraege[id].AccountID == acc.ID {
antragID = id
}
}
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
// Ohne werkzeug_id: abgelehnt.
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code)
}
// Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden".
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") {
t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String())
}
}
func TestFallDetailRejectsForeignAccount(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher")
resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code)
}
}