package server import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" mcpserver "github.com/mark3labs/mcp-go/server" "go.uber.org/zap" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/llm/conversationlog" ) func newConversationHandler(t *testing.T, dir string) *Handler { t.Helper() g := graph.New() srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false)) h := NewHandler(srv, g, "0.0.1-test", zap.NewNop()) h.SetConversationDir(dir) return h } // seedSession writes a session JSONL file directly into dir so the route // tests don't need a live LLM. func seedSession(t *testing.T, dir, session string, recs []conversationlog.Record) { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } f, err := os.Create(filepath.Join(dir, session+".jsonl")) if err != nil { t.Fatal(err) } defer f.Close() enc := json.NewEncoder(f) for _, rec := range recs { if err := enc.Encode(&rec); err != nil { t.Fatal(err) } } } func TestConversations_ListAndLoad(t *testing.T) { dir := t.TempDir() seedSession(t, dir, "sess-A", []conversationlog.Record{ {Session: "sess-A", File: "a.go", Phase: "plan", Response: "r1", InputTokens: 10, OutputTokens: 2, Estimated: true}, {Session: "sess-A", File: "b.go", Phase: "main", Response: "r2", InputTokens: 20, OutputTokens: 4, Estimated: true}, }) h := newConversationHandler(t, dir) // Loopback host so the guard allows without a token. srv := httptest.NewServer(h) defer srv.Close() // List. resp, err := http.Get(srv.URL + "/v1/conversations") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d", resp.StatusCode) } var list struct { Sessions []conversationlog.SessionSummary `json:"sessions"` } if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { t.Fatal(err) } if len(list.Sessions) != 1 || list.Sessions[0].Session != "sess-A" { t.Fatalf("list = %+v, want one session sess-A", list.Sessions) } if list.Sessions[0].Records != 2 { t.Fatalf("session records = %d, want 2", list.Sessions[0].Records) } // Load session — full set. resp2, err := http.Get(srv.URL + "/v1/conversations/sess-A") if err != nil { t.Fatal(err) } defer resp2.Body.Close() var got struct { Session string `json:"session"` Records []conversationlog.Record `json:"records"` Total int `json:"total"` Filtered int `json:"filtered"` } if err := json.NewDecoder(resp2.Body).Decode(&got); err != nil { t.Fatal(err) } if got.Total != 2 || got.Filtered != 2 || len(got.Records) != 2 { t.Fatalf("load = %+v, want 2 records", got) } if got.Records[0].InputTokens != 10 || !got.Records[0].Estimated { t.Fatalf("usage not returned: %+v", got.Records[0]) } // Filter by file. resp3, err := http.Get(srv.URL + "/v1/conversations/sess-A?file=b.go") if err != nil { t.Fatal(err) } defer resp3.Body.Close() var gotF struct { Records []conversationlog.Record `json:"records"` Total int `json:"total"` Filtered int `json:"filtered"` } if err := json.NewDecoder(resp3.Body).Decode(&gotF); err != nil { t.Fatal(err) } if gotF.Total != 2 || gotF.Filtered != 1 || len(gotF.Records) != 1 { t.Fatalf("file filter = %+v, want 1 of 2", gotF) } if gotF.Records[0].File != "b.go" { t.Fatalf("file filter returned %q", gotF.Records[0].File) } } func TestConversations_UI_HTML(t *testing.T) { h := newConversationHandler(t, t.TempDir()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/v1/conversations/ui", nil) req.Host = "localhost" h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("ui status = %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { t.Fatalf("ui content-type = %q", ct) } if body := rec.Body.String(); len(body) == 0 || body[:9] != "