web_test.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "mime/multipart" |
| 9 | "net/http" |
| 10 | "net/http/cookiejar" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | |
| 17 | "forge.gpl-nas.de/physiobase/internal/config" |
| 18 | "forge.gpl-nas.de/physiobase/internal/database" |
| 19 | "forge.gpl-nas.de/physiobase/internal/store" |
| 20 | ) |
| 21 | |
| 22 | func newServer(t *testing.T) (*httptest.Server, *http.Client) { |
| 23 | t.Helper() |
| 24 | return newServerCfg(t, nil) |
| 25 | } |
| 26 | |
| 27 | func newServerCfg(t *testing.T, mod func(*config.Config)) (*httptest.Server, *http.Client) { |
| 28 | t.Helper() |
| 29 | dir := t.TempDir() |
| 30 | db, err := database.Open(filepath.Join(dir, "t.db")) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | t.Cleanup(func() { database.Close(db) }) |
| 35 | hash, _ := HashPassword("geheim123") |
| 36 | cfg := &config.Config{Username: "admin", PasswordHash: hash, PracticeName: "Test", MediaDir: dir, DocsDir: filepath.Join(dir, "docs")} |
| 37 | if mod != nil { |
| 38 | mod(cfg) |
| 39 | } |
| 40 | st := store.New(db) |
| 41 | if _, err := st.Seed(context.Background()); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | srv, err := New(context.Background(), cfg, st, slog.New(slog.NewTextHandler(io.Discard, nil))) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | ts := httptest.NewServer(srv.Handler()) |
| 49 | t.Cleanup(ts.Close) |
| 50 | jar, _ := cookiejar.New(nil) |
| 51 | c := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 52 | return ts, c |
| 53 | } |
| 54 | |
| 55 | func post(c *http.Client, u string, v url.Values) (*http.Response, error) { |
| 56 | req, _ := http.NewRequest(http.MethodPost, u, strings.NewReader(v.Encode())) |
| 57 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 58 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 59 | return c.Do(req) |
| 60 | } |
| 61 | |
| 62 | func body(t *testing.T, c *http.Client, u string) (int, string) { |
| 63 | t.Helper() |
| 64 | r, err := c.Get(u) |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | defer r.Body.Close() |
| 69 | b, _ := io.ReadAll(r.Body) |
| 70 | return r.StatusCode, string(b) |
| 71 | } |
| 72 | |
| 73 | func TestAuthFlow(t *testing.T) { |
| 74 | ts, c := newServer(t) |
| 75 | if code, _ := body(t, c, ts.URL+"/"); code != http.StatusSeeOther { |
| 76 | t.Fatalf("ohne Anmeldung: %d, erwartet Redirect", code) |
| 77 | } |
| 78 | if code, _ := body(t, c, ts.URL+"/healthz"); code != 200 { |
| 79 | t.Fatalf("healthz: %d", code) |
| 80 | } |
| 81 | r, _ := post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"falsch"}}) |
| 82 | if r.StatusCode != http.StatusUnauthorized { |
| 83 | t.Fatalf("falsches Passwort: %d", r.StatusCode) |
| 84 | } |
| 85 | r, _ = post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}}) |
| 86 | if r.StatusCode != http.StatusSeeOther { |
| 87 | t.Fatalf("login: %d", r.StatusCode) |
| 88 | } |
| 89 | code, html := body(t, c, ts.URL+"/?q=knie") |
| 90 | if code != 200 || !strings.Contains(html, "Patellatendinopathie") { |
| 91 | t.Fatalf("Suche nach Login: %d", code) |
| 92 | } |
| 93 | // Fremde Seite darf nichts posten (CSRF) |
| 94 | req, _ := http.NewRequest(http.MethodPost, ts.URL+"/eintrag/1/loeschen", nil) |
| 95 | req.Header.Set("Sec-Fetch-Site", "cross-site") |
| 96 | r, _ = c.Do(req) |
| 97 | if r.StatusCode != http.StatusForbidden { |
| 98 | t.Fatalf("cross-site POST: %d, erwartet 403", r.StatusCode) |
| 99 | } |
| 100 | // Abmelden macht das alte Cookie ungültig |
| 101 | post(c, ts.URL+"/logout", nil) |
| 102 | if code, _ := body(t, c, ts.URL+"/"); code != http.StatusSeeOther { |
| 103 | t.Fatal("nach Abmelden noch angemeldet") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestPagesRender(t *testing.T) { |
| 108 | ts, c := newServer(t) |
| 109 | post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}}) |
| 110 | for _, p := range []string{"/", "/eintrag/1", "/eintrag/3", "/eintrag/1/bearbeiten", "/eintrag/neu?art=uebung", "/handouts", "/handout/1", "/handout/1/druck"} { |
| 111 | if code, html := body(t, c, ts.URL+p); code != 200 || !strings.Contains(html, "</html>") { |
| 112 | t.Errorf("%s: %d", p, code) |
| 113 | } |
| 114 | } |
| 115 | if code, _ := body(t, c, ts.URL+"/eintrag/9999"); code != 404 { |
| 116 | t.Errorf("unbekannter Eintrag: %d", code) |
| 117 | } |
| 118 | if code, _ := body(t, c, ts.URL+"/medien/..%2f..%2fetc%2fpasswd"); code != 404 { |
| 119 | t.Errorf("Pfad-Trick: %d", code) |
| 120 | } |
| 121 | // Neuer Eintrag, verknüpft angelegt |
| 122 | r, _ := post(c, ts.URL+"/eintrag/neu", url.Values{"kind": {"test"}, "title": {"Lachman-Test"}, "region": {"Knie"}, "link_from": {"1"}}) |
| 123 | if r.StatusCode != http.StatusSeeOther { |
| 124 | t.Fatalf("anlegen: %d", r.StatusCode) |
| 125 | } |
| 126 | if _, html := body(t, c, ts.URL+"/eintrag/1"); !strings.Contains(html, "Lachman-Test") { |
| 127 | t.Error("neuer Eintrag nicht verknüpft") |
| 128 | } |
| 129 | if _, html := body(t, c, ts.URL+"/handout/1/druck"); !strings.Contains(html, "Wandsitz") { |
| 130 | t.Error("Handout-Druck ohne Übung") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestPatientBefundFlow(t *testing.T) { |
| 135 | ts, c := newServer(t) |
| 136 | post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}}) |
| 137 | |
| 138 | r, _ := post(c, ts.URL+"/patienten", url.Values{"name": {"Max Test"}, "dob": {"1980"}}) |
| 139 | if r.StatusCode != http.StatusSeeOther || r.Header.Get("Location") != "/patient/1" { |
| 140 | t.Fatalf("Patient anlegen: %d %s", r.StatusCode, r.Header.Get("Location")) |
| 141 | } |
| 142 | r, _ = post(c, ts.URL+"/patient/1/befund", url.Values{}) |
| 143 | if loc := r.Header.Get("Location"); loc != "/befund/1" { |
| 144 | t.Fatalf("Befund anlegen: %s", loc) |
| 145 | } |
| 146 | if code, html := body(t, c, ts.URL+"/befund/1"); code != 200 || !strings.Contains(html, "Red Flags") || !strings.Contains(html, `name="t.rom.n"`) { |
| 147 | t.Fatalf("Befundformular: %d", code) |
| 148 | } |
| 149 | r, _ = post(c, ts.URL+"/befund/1", url.Values{ |
| 150 | "title": {"Erstbefund"}, "date": {"2025-01-10"}, |
| 151 | "f.diag": {"Lumbalgie"}, "f.pain_now": {"7"}, "t.rom.n": {"1"}, "t.rom.0.joint": {"LWS"}, "t.rom.0.move": {"Flex"}, "t.rom.0.act": {"40"}, |
| 152 | "stamps": {`[{"v":1,"x":0.5,"y":0.45,"s":"pain","c":"#d32f2f","z":2}]`}, |
| 153 | }) |
| 154 | if r.StatusCode != http.StatusSeeOther { |
| 155 | t.Fatalf("Befund speichern: %d", r.StatusCode) |
| 156 | } |
| 157 | // Wiederbefund als Kopie, dann Wert ändern und vergleichen |
| 158 | r, _ = post(c, ts.URL+"/patient/1/befund", url.Values{"kopie": {"1"}}) |
| 159 | if loc := r.Header.Get("Location"); loc != "/befund/2" { |
| 160 | t.Fatalf("Wiederbefund: %s", loc) |
| 161 | } |
| 162 | if _, html := body(t, c, ts.URL+"/befund/2"); !strings.Contains(html, "Lumbalgie") || !strings.Contains(html, "Wiederbefund") { |
| 163 | t.Fatal("Kopie hat Daten nicht übernommen") |
| 164 | } |
| 165 | post(c, ts.URL+"/befund/2", url.Values{ |
| 166 | "title": {"Wiederbefund"}, "date": {"2025-02-10"}, "f.diag": {"Lumbalgie"}, "f.pain_now": {"3"}, |
| 167 | "t.rom.n": {"1"}, "t.rom.0.joint": {"LWS"}, "t.rom.0.move": {"Flex"}, "t.rom.0.act": {"55"}, |
| 168 | }) |
| 169 | code, html := body(t, c, ts.URL+"/patient/1/vergleich") |
| 170 | if code != 200 || !strings.Contains(html, "+15") || !strings.Contains(html, "NRS aktuell") { |
| 171 | t.Fatalf("Vergleich: %d\n%s", code, html[strings.Index(html, "<main"):]) |
| 172 | } |
| 173 | if _, html := body(t, c, ts.URL+"/befund/1/druck"); !strings.Contains(html, "Lumbalgie") || !strings.Contains(html, "<text") || strings.Contains(html, "Fachbereich") { |
| 174 | t.Error("Druckansicht unvollständig") |
| 175 | } |
| 176 | // Verlauf, Handout, Wissen |
| 177 | post(c, ts.URL+"/patient/1/verlauf", url.Values{"date": {"2025-01-12"}, "goal": {"Bewegungsangst reduzieren"}, "note": {"gut"}}) |
| 178 | post(c, ts.URL+"/patient/1/wissen", url.Values{"entry": {"1"}}) |
| 179 | r, _ = post(c, ts.URL+"/patient/1/handout", url.Values{"title": {"Woche 1"}}) |
| 180 | if !strings.HasPrefix(r.Header.Get("Location"), "/handout/") { |
| 181 | t.Fatal("Handout für Patient") |
| 182 | } |
| 183 | _, html = body(t, c, ts.URL+"/patient/1") |
| 184 | for _, want := range []string{"Bewegungsangst", "Woche 1", "Patellatendinopathie", "Verlauf der Kernwerte"} { |
| 185 | if !strings.Contains(html, want) { |
| 186 | t.Errorf("Patientenseite ohne %q", want) |
| 187 | } |
| 188 | } |
| 189 | // Export → Import ergibt denselben Patienten mit drittem Befund |
| 190 | resp, _ := c.Get(ts.URL + "/befund/2/export") |
| 191 | exp, _ := io.ReadAll(resp.Body) |
| 192 | resp.Body.Close() |
| 193 | if code := importFiles(t, c, ts.URL, exp); code != http.StatusSeeOther { |
| 194 | t.Fatalf("Import Export-Datei: %d", code) |
| 195 | } |
| 196 | if _, html := body(t, c, ts.URL+"/patient/1"); strings.Count(html, `href="/befund/`) < 3 { |
| 197 | t.Error("Import nicht beim selben Patienten") |
| 198 | } |
| 199 | // Alte Befundseite |
| 200 | old := `{"name":"Erika Alt","date":"2024-12-01","diag":"Schulter","rom":[{"joint":"Schulter","move":"Abd","act":"90","pass":"120","end":""}],"treat":[{"date":"2024-12-02","goal":"Mobilisation","note":""}],"_v":2}` |
| 201 | if code := importFiles(t, c, ts.URL, []byte(old)); code != http.StatusSeeOther { |
| 202 | t.Fatalf("Import alt: %d", code) |
| 203 | } |
| 204 | if _, html := body(t, c, ts.URL+"/patienten"); !strings.Contains(html, "Erika Alt") { |
| 205 | t.Error("importierter Patient fehlt") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func importFiles(t *testing.T, c *http.Client, base string, files ...[]byte) int { |
| 210 | t.Helper() |
| 211 | var buf strings.Builder |
| 212 | w := multipart.NewWriter(&buf) |
| 213 | for i, f := range files { |
| 214 | fw, _ := w.CreateFormFile("files", fmt.Sprintf("Befund_%d.json", i)) |
| 215 | fw.Write(f) |
| 216 | } |
| 217 | w.Close() |
| 218 | req, _ := http.NewRequest(http.MethodPost, base+"/patienten/import", strings.NewReader(buf.String())) |
| 219 | req.Header.Set("Content-Type", w.FormDataContentType()) |
| 220 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 221 | r, err := c.Do(req) |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | r.Body.Close() |
| 226 | return r.StatusCode |
| 227 | } |
| 228 |