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/localchat/internal/config" ) // fakeLLM streams back "echo: " and records what it got. func fakeLLM(t *testing.T, seen *[][]map[string]string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/models" { fmt.Fprint(w, `{"data":[{"id":"fake"}]}`) return } var req struct { Messages []map[string]string `json:"messages"` } json.NewDecoder(r.Body).Decode(&req) *seen = append(*seen, req.Messages) last := req.Messages[len(req.Messages)-1]["content"] w.Header().Set("Content-Type", "text/event-stream") for _, part := range []string{"echo: ", last, "\n\n**bold** "} { b, _ := json.Marshal(part) fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b) } fmt.Fprint(w, "data: [DONE]\n\n") })) } func newTestApp(t *testing.T, llmURL string) (*httptest.Server, *http.Client) { t.Helper() cfg := config.Config{ BaseURL: llmURL + "/v1", SystemPrompt: "be nice", Title: "test", MaxHistory: 20, Timeout: 10 * time.Second, } 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="(/chat/stream/[0-9a-f]+)"`) func get(t *testing.T, c *http.Client, u string) string { t.Helper() resp, err := c.Get(u) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } // ask posts a message, follows the SSE stream and returns the raw events. func ask(t *testing.T, app *httptest.Server, c *http.Client, msg string) string { t.Helper() resp, err := c.PostForm(app.URL+"/chat", url.Values{"message": {msg}}) if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() m := streamURL.FindStringSubmatch(string(body)) if m == nil { t.Fatalf("no stream url in %s", body) } return get(t, c, app.URL+m[1]) } func TestChatFlow(t *testing.T) { var seen [][]map[string]string llm := fakeLLM(t, &seen) defer llm.Close() app, c := newTestApp(t, llm.URL) if page := get(t, c, app.URL+"/"); !strings.Contains(page, `id="composer"`) { t.Fatal("index page missing composer") } if h := get(t, c, app.URL+"/health"); !strings.Contains(h, "dot ok") || !strings.Contains(h, "fake") { t.Fatalf("health: %s", h) } events := ask(t, app, c, "hi there") for _, want := range []string{ "event: token\ndata: echo: ", "hi <b>there</b>", // user text is escaped in tokens "event: done", "bold", // final reply is rendered Markdown "", // ... with raw HTML from the model dropped } { if !strings.Contains(events, want) { t.Errorf("stream missing %q:\n%s", want, events) } } if strings.Contains(events, "