Files
fathom/internal/web/server_test.go
zzgandClaude Opus 5 79fe343be5 Move the module path to git.z.gd/zzg/fathom
The module was declared as github.com/zzg/fathom, which is not where
this repo lives and never has been. Nothing is published at that path,
so `go get github.com/zzg/fathom` resolves to a 404 while the import
paths in every file assert otherwise.

git.z.gd is a real module path rather than a cosmetic correction:
Gitea serves the go-import meta tag for it,

  <meta name="go-import" content="git.z.gd/zzg/fathom git https://git.z.gd/zzg/fathom.git">

and the repo answers unauthenticated over HTTPS, so `go get
git.z.gd/zzg/fathom` resolves where the old path could not.

Mechanical: the module line plus 46 import paths across 23 files. There
is no go.sum to churn, since fathom has no third-party dependencies.
go build, go vet, gofmt and the full test suite are clean under the new
path, and the built binary still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:31:18 -07:00

482 lines
15 KiB
Go

package web
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"git.z.gd/zzg/fathom/internal/ingest"
"git.z.gd/zzg/fathom/internal/insights"
"git.z.gd/zzg/fathom/internal/model"
"git.z.gd/zzg/fathom/internal/store"
)
func testServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
st, err := store.Open(store.Options{
Dir: t.TempDir(), FlushInterval: time.Hour, FlushRows: 1 << 20,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
srv := NewServer(st, ingest.NewDecoder(), nil, log.New(io.Discard, "", 0))
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
func post(t *testing.T, url, body string) *http.Response {
t.Helper()
res, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
return res
}
func getJSON(t *testing.T, url string, into any) {
t.Helper()
res, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
b, _ := io.ReadAll(res.Body)
t.Fatalf("GET %s -> %d: %s", url, res.StatusCode, b)
}
if err := json.NewDecoder(res.Body).Decode(into); err != nil {
t.Fatal(err)
}
}
// TestIngestToTraceDetail is the whole pipeline in one test: real captured OTLP
// goes in over HTTP, and a readable trace comes back out. Every layer in
// between — decode, store, flush, scan, correlate — has its own tests; this is
// the one that fails if they stop fitting together.
func TestIngestToTraceDetail(t *testing.T) {
ts, st := testServer(t)
traceJSON, err := os.ReadFile("../ingest/testdata/claude_code_traces.json")
if err != nil {
t.Fatal(err)
}
logJSON, err := os.ReadFile("../ingest/testdata/claude_code_logs_content.json")
if err != nil {
t.Fatal(err)
}
if res := post(t, ts.URL+"/v1/traces", string(traceJSON)); res.StatusCode != 200 {
t.Fatalf("POST /v1/traces -> %d", res.StatusCode)
}
if res := post(t, ts.URL+"/v1/logs", string(logJSON)); res.StatusCode != 200 {
t.Fatalf("POST /v1/logs -> %d", res.StatusCode)
}
if err := st.Flush(); err != nil {
t.Fatal(err)
}
// The fixture's timestamps are fixed, so the window has to be wide enough
// to contain them regardless of when the test runs.
from, to := int64(1_600_000_000_000_000), int64(2_100_000_000_000_000)
var list struct {
Traces []TraceSummary `json:"traces"`
Total int `json:"total"`
}
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, from, to), &list)
if list.Total != 1 {
t.Fatalf("got %d traces, want 1 (the fixture is a single run)", list.Total)
}
summary := list.Traces[0]
if summary.SpanCount == 0 {
t.Error("trace summary reports no spans")
}
if summary.ToolCalls == 0 {
t.Error("trace summary reports no tool calls, but the fixture ran Bash and Read")
}
if summary.LLMCalls == 0 {
t.Error("trace summary reports no model calls")
}
if summary.Preview == "" {
t.Error("trace summary has no prompt preview; the list would be unreadable")
}
var detail struct {
Trace struct {
TraceID string `json:"trace_id"`
SpanCount int `json:"span_count"`
} `json:"trace"`
Flat []struct {
Span struct {
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"span"`
Logs []struct {
EventName string `json:"event_name"`
} `json:"logs"`
} `json:"flat"`
LogCount int `json:"log_count"`
}
getJSON(t, fmt.Sprintf("%s/api/traces/%s?from=%d&to=%d", ts.URL, summary.TraceID, from, to), &detail)
if detail.Trace.SpanCount != summary.SpanCount {
t.Errorf("detail reports %d spans, list reported %d", detail.Trace.SpanCount, summary.SpanCount)
}
if detail.LogCount == 0 {
t.Error("detail carries no log records, so the transcript would be empty")
}
// Kind must arrive as a name. If this returns "2" the client would have to
// keep its own copy of the enum.
for _, n := range detail.Flat {
if n.Span.Kind == "" || (n.Span.Kind[0] >= '0' && n.Span.Kind[0] <= '9') {
t.Fatalf("span kind marshalled as %q, want a name like tool_call", n.Span.Kind)
}
}
// The correlation must spread content over more than one span.
withLogs := 0
for _, n := range detail.Flat {
if len(n.Logs) > 0 {
withLogs++
}
}
if withLogs < 2 {
t.Errorf("only %d span(s) carry log records; correlation collapsed", withLogs)
}
}
func TestTraceDetailNotFound(t *testing.T) {
ts, _ := testServer(t)
res, err := http.Get(ts.URL + "/api/traces/does-not-exist")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("unknown trace -> %d, want 404", res.StatusCode)
}
}
// TestMetricsAccepted covers the signal Fathom deliberately discards. Exporters
// send all three to one endpoint; a 404 here would fill the agent's own logs
// with errors about a decision Fathom made on purpose.
func TestMetricsAccepted(t *testing.T) {
ts, _ := testServer(t)
res := post(t, ts.URL+"/v1/metrics", `{"resourceMetrics":[]}`)
if res.StatusCode != 200 {
t.Errorf("POST /v1/metrics -> %d, want 200", res.StatusCode)
}
}
func TestIngestRejectsGarbage(t *testing.T) {
ts, _ := testServer(t)
for _, path := range []string{"/v1/traces", "/v1/logs"} {
res := post(t, ts.URL+path, "not json at all")
if res.StatusCode != http.StatusBadRequest {
t.Errorf("POST %s with garbage -> %d, want 400", path, res.StatusCode)
}
}
}
func TestQueryEndpoint(t *testing.T) {
ts, st := testServer(t)
traceJSON, err := os.ReadFile("../ingest/testdata/claude_code_traces.json")
if err != nil {
t.Fatal(err)
}
post(t, ts.URL+"/v1/traces", string(traceJSON))
if err := st.Flush(); err != nil {
t.Fatal(err)
}
body := fmt.Sprintf(`{"query":"SELECT kind, count(*) FROM spans WHERE time >= %d AND time <= %d GROUP BY kind ORDER BY count(*) DESC"}`,
1_600_000_000_000_000, 2_100_000_000_000_000)
res := post(t, ts.URL+"/api/query", body)
defer res.Body.Close()
if res.StatusCode != 200 {
b, _ := io.ReadAll(res.Body)
t.Fatalf("POST /api/query -> %d: %s", res.StatusCode, b)
}
var out struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
OrderBy string `json:"order_by"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if len(out.Rows) == 0 {
t.Fatal("query returned no rows")
}
// The UI charts the ORDER BY measure; without this it charts the wrong one.
if out.OrderBy != "count(*)" {
t.Errorf("order_by = %q, want count(*)", out.OrderBy)
}
}
func TestQueryErrorIsReturnedVerbatim(t *testing.T) {
ts, _ := testServer(t)
res := post(t, ts.URL+"/api/query", `{"query":"SELECT name FROM spans"}`)
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("missing time range -> %d, want 400", res.StatusCode)
}
var out struct {
Error string `json:"error"`
}
json.NewDecoder(res.Body).Decode(&out)
// The message must be the parser's, not a generic one: it is the only
// guidance the person writing the query gets.
if !strings.Contains(out.Error, "time range") {
t.Errorf("error = %q, want it to explain the missing time range", out.Error)
}
}
func TestUIIsServed(t *testing.T) {
ts, _ := testServer(t)
for _, path := range []string{"/", "/app.js", "/style.css"} {
res, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 200 {
t.Errorf("GET %s -> %d", path, res.StatusCode)
}
}
// A deep link must serve the app rather than 404, or reloading on a trace
// page breaks.
res, err := http.Get(ts.URL + "/some/client/route")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
t.Errorf("deep link -> %d, want the SPA shell", res.StatusCode)
}
b, _ := io.ReadAll(res.Body)
if !strings.Contains(string(b), "<title>Fathom</title>") {
t.Error("deep link did not serve index.html")
}
}
// TestPartialTraceIsMarked covers a run still in flight. Claude Code emits the
// root interaction span when the interaction ends, so a list rendered mid-run
// sees every child and no root — and without this, names the trace after
// whichever child landed first, which reads as a finished trace with a strange
// name.
func TestPartialTraceIsMarked(t *testing.T) {
ts, st := testServer(t)
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC).UnixMicro()
// A child span with no root, exactly as an in-flight run looks.
if err := st.AddSpans([]model.Span{{
TraceID: "in-flight", SpanID: "child", ParentSpanID: "root-not-here",
Name: "claude_code.tool.blocked_on_user", StartTS: base, EndTS: base + 1000,
SampleRate: 1,
}}); err != nil {
t.Fatal(err)
}
if err := st.Flush(); err != nil {
t.Fatal(err)
}
var list struct {
Traces []TraceSummary `json:"traces"`
}
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, base-1, base+100000), &list)
if len(list.Traces) != 1 {
t.Fatalf("got %d traces, want 1", len(list.Traces))
}
if !list.Traces[0].Partial {
t.Error("a trace with no root span was not marked partial")
}
if list.Traces[0].Name != "" {
t.Errorf("partial trace was named %q; it should carry no name rather than a child's",
list.Traces[0].Name)
}
// Once the root lands, the trace is complete and correctly named.
if err := st.AddSpans([]model.Span{{
TraceID: "in-flight", SpanID: "root-not-here", Name: "claude_code.interaction",
StartTS: base - 500, EndTS: base + 2000, SampleRate: 1,
}}); err != nil {
t.Fatal(err)
}
if err := st.Flush(); err != nil {
t.Fatal(err)
}
list.Traces = nil
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, base-1000, base+100000), &list)
if list.Traces[0].Partial {
t.Error("trace still marked partial after its root arrived")
}
if list.Traces[0].Name != "claude_code.interaction" {
t.Errorf("name = %q, want claude_code.interaction", list.Traces[0].Name)
}
}
// TestInsightsEmptyStoreIsWellFormed covers the response a brand new
// installation gets. It shipped with themes marshalling as JSON null, which the
// client iterated and crashed on — so the insights tab rendered a raw
// JavaScript error on exactly the store every new user starts with.
func TestInsightsEmptyStoreIsWellFormed(t *testing.T) {
_, st := testServer(t)
analyzer := insights.New(insights.Options{Store: st, WindowDays: 7})
analyzer.Refresh()
srv := NewServer(st, ingest.NewDecoder(), nil, log.New(io.Discard, "", 0))
srv.SetInsights(analyzer)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/insights", nil))
if rec.Code != 200 {
t.Fatalf("GET /api/insights -> %d", rec.Code)
}
// Assert on the raw JSON: a nil slice decodes into an empty Go slice, so
// checking the decoded form would hide the bug this guards.
body := rec.Body.String()
if !strings.Contains(body, `"themes":[]`) {
t.Errorf("themes must serialise as [], not null: %s", body)
}
// No field the page dereferences may arrive as null. The client normalises
// defensively as well, but a server that emits null here is a bug on its
// own: the two ship in the same binary and should not disagree.
var loose map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &loose); err != nil {
t.Fatal(err)
}
result, _ := loose["result"].(map[string]any)
if result == nil {
t.Fatal("result is missing or null")
}
for _, field := range []string{"themes", "window", "local"} {
if v, ok := result[field]; !ok || v == nil {
t.Errorf("result.%s is %v; the page dereferences it", field, v)
}
}
var out struct {
Ready bool `json:"ready"`
Result struct {
Themes []any `json:"themes"`
TraceCount int `json:"trace_count"`
MinOccurrences int `json:"min_occurrences"`
Dismissed int `json:"dismissed"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if !out.Ready {
t.Error("an analysis of an empty store completed, so ready should be true")
}
if out.Result.TraceCount != 0 {
t.Errorf("trace_count = %d, want 0", out.Result.TraceCount)
}
// The empty-state copy quotes this threshold, so it has to be present or
// the wording silently drifts from the detectors' behaviour.
if out.Result.MinOccurrences < 1 {
t.Errorf("min_occurrences = %d; the empty state has no threshold to quote",
out.Result.MinOccurrences)
}
if out.Result.Dismissed != 0 {
t.Errorf("dismissed = %d, want 0", out.Result.Dismissed)
}
}
// TestInsightsReportsDismissedCount: an empty page because everything was
// dismissed is a different situation from an empty page because nothing was
// found, and the UI needs to tell them apart.
func TestInsightsReportsDismissedCount(t *testing.T) {
_, st := testServer(t)
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC).UnixMicro()
var spans []model.Span
for i := 0; i < 4; i++ {
tid := fmt.Sprintf("t%d", i)
spans = append(spans, model.Span{
TraceID: tid, SpanID: tid + "-root", Name: "claude_code.interaction",
Kind: model.KindAgentStep, StartTS: base + int64(i)*1000, EndTS: base + int64(i)*1000 + 900,
SampleRate: 1,
})
// A failing command shape, repeated, which the waste detector reports.
for j := 0; j < 2; j++ {
spans = append(spans, model.Span{
TraceID: tid, SpanID: fmt.Sprintf("%s-tool%d", tid, j), ParentSpanID: tid + "-root",
Name: "claude_code.tool", Kind: model.KindToolCall, Status: model.StatusError,
StartTS: base + int64(i)*1000, EndTS: base + int64(i)*1000 + 100, SampleRate: 1,
Attrs: model.Attrs{"tool_name": model.StringValue("Bash")},
})
}
}
if err := st.AddSpans(spans); err != nil {
t.Fatal(err)
}
if err := st.Flush(); err != nil {
t.Fatal(err)
}
analyzer := insights.New(insights.Options{
Store: st, WindowDays: 3650,
Now: func() time.Time { return time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC) },
})
analyzer.Refresh()
res, _ := analyzer.Latest()
var ids []string
for _, th := range res.Themes {
for _, in := range th.Insights {
ids = append(ids, in.ID)
}
}
if len(ids) == 0 {
t.Fatal("no findings to dismiss; this test would pass vacuously")
}
for _, id := range ids {
analyzer.Dismiss(id)
}
res, _ = analyzer.Latest()
shown := 0
for _, th := range res.Themes {
shown += len(th.Insights)
}
if shown != 0 {
t.Errorf("%d findings still shown after dismissing all of them", shown)
}
if res.Dismissed != len(ids) {
t.Errorf("dismissed = %d, want %d", res.Dismissed, len(ids))
}
// Restoring must bring them all back, or a dismissal is effectively
// permanent and the button lies.
analyzer.Restore()
res, _ = analyzer.Latest()
shown = 0
for _, th := range res.Themes {
shown += len(th.Insights)
}
if shown != len(ids) {
t.Errorf("after restore %d findings are shown, want %d", shown, len(ids))
}
if res.Dismissed != 0 {
t.Errorf("dismissed = %d after restore, want 0", res.Dismissed)
}
}