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, store.AccountInput{Name: "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(), store.AccountInput{Name: "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()) } } // TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die // tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die // daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung // nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel). func TestFallDetailZeigtFragebogenAntworten(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()) } body := resp.Body.String() if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") { t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body) } if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") { t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body) } } 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(), store.AccountInput{Name: "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(), store.AccountInput{Name: "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) } }