package web import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" "git.b0b.be/bdeb/workout-suggester/internal/config" ) // fakeUpstreams serves Open-Meteo (real Ghent fixtures), Overpass and an LLM // that echoes the start of its system prompt, recording what it was sent. func fakeUpstreams(t *testing.T, prompts *[]string) *httptest.Server { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/forecast": http.ServeFile(w, r, "../weather/testdata/forecast.json") case "/v1/search": http.ServeFile(w, r, "../weather/testdata/geocode.json") case "/overpass": fmt.Fprint(w, `{"elements":[{"type":"relation","id":7,"tags":{"route":"foot","name":"Bourgoyenpad","roundtrip":"yes"}, "members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.775}]}]}]}`) case "/brouter": http.ServeFile(w, r, "../routes/testdata/brouter.json") case "/llm/models": fmt.Fprint(w, `{"data":[{"id":"fake-gemma"}]}`) case "/llm/chat/completions": var req struct { Messages []struct{ Role, Content string } } json.NewDecoder(r.Body).Decode(&req) *prompts = append(*prompts, req.Messages[0].Content+"\n---\n"+req.Messages[len(req.Messages)-1].Content) w.Header().Set("Content-Type", "text/event-stream") for _, part := range []string{"**Go for a run** ", ""} { b, _ := json.Marshal(part) fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b) } fmt.Fprint(w, "data: [DONE]\n\n") default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) return srv } func newTestApp(t *testing.T, prompts *[]string) (*httptest.Server, *http.Client) { t.Helper() up := fakeUpstreams(t, prompts) cfg := config.Config{ BaseURL: up.URL + "/llm", Timeout: 10 * time.Second, OverpassURL: up.URL + "/overpass", BRouterURL: up.URL + "/brouter", ForecastURL: up.URL + "/v1/forecast", GeocodeURL: up.URL + "/v1/search", } app := httptest.NewServer(New(cfg, "", slog.New(slog.NewTextHandler(io.Discard, nil))).Handler()) t.Cleanup(app.Close) jar, _ := cookiejar.New(nil) return app, &http.Client{Jar: jar} } var streamURL = regexp.MustCompile(`sse-connect="(/stream/[0-9a-f]+)"`) func do(t *testing.T, c *http.Client, method, u string, form url.Values) string { t.Helper() var resp *http.Response var err error if method == http.MethodPost { resp, err = c.PostForm(u, form) } else { resp, err = c.Get(u) } if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } func TestPlanFlow(t *testing.T) { var prompts []string app, c := newTestApp(t, &prompts) page := do(t, c, "GET", app.URL+"/", nil) if !strings.Contains(page, "Get me outside") { t.Fatal("index lacks the form") } plan := do(t, c, "POST", app.URL+"/plan", url.Values{ "place": {"Gent"}, "activity": {"run"}, "intensity": {"easy"}, "minutes": {"30"}, "note": {"bad knee"}, }) for _, want := range []string{"Ghent, Belgium", "15°", "great", `hx-get="/routes"`} { if !strings.Contains(plan, want) { t.Errorf("plan lacks %q", want) } } m := streamURL.FindStringSubmatch(plan) if m == nil { t.Fatal("plan has no streaming reply") } // The first reply waits for routes, so the model sees them. stream := do(t, c, "GET", app.URL+m[1], nil) if !strings.Contains(stream, "event: done") || !strings.Contains(stream, "Go for a run") { t.Errorf("stream = %s", stream) } if strings.Contains(stream, "