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