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 return newServerCfg(t, nil)
25}
26
27func 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
55func 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
62func 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
73func 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
107func 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
134func 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, "&#43;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
209func 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