mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
272 lines
10 KiB
Go
272 lines
10 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type mockTracingStore struct {
|
|
listOpts []store.TraceListOpts
|
|
traces []store.TraceData
|
|
spans map[uuid.UUID][]store.SpanData
|
|
}
|
|
|
|
func (m *mockTracingStore) CreateTrace(context.Context, *store.TraceData) error { return nil }
|
|
func (m *mockTracingStore) UpdateTrace(context.Context, uuid.UUID, map[string]any) error {
|
|
return nil
|
|
}
|
|
func (m *mockTracingStore) GetTrace(context.Context, uuid.UUID) (*store.TraceData, error) {
|
|
return nil, errors.New("not found")
|
|
}
|
|
func (m *mockTracingStore) ListTraces(_ context.Context, opts store.TraceListOpts) ([]store.TraceData, error) {
|
|
m.listOpts = append(m.listOpts, opts)
|
|
return m.traces, nil
|
|
}
|
|
func (m *mockTracingStore) CountTraces(context.Context, store.TraceListOpts) (int, error) {
|
|
return len(m.traces), nil
|
|
}
|
|
func (m *mockTracingStore) CreateSpan(context.Context, *store.SpanData) error { return nil }
|
|
func (m *mockTracingStore) UpdateSpan(context.Context, uuid.UUID, map[string]any) error {
|
|
return nil
|
|
}
|
|
func (m *mockTracingStore) GetTraceSpans(_ context.Context, traceID uuid.UUID) ([]store.SpanData, error) {
|
|
return m.spans[traceID], nil
|
|
}
|
|
func (m *mockTracingStore) ListChildTraces(context.Context, uuid.UUID) ([]store.TraceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockTracingStore) BatchCreateSpans(context.Context, []store.SpanData) error {
|
|
return nil
|
|
}
|
|
func (m *mockTracingStore) BatchUpdateTraceAggregates(context.Context, uuid.UUID) error {
|
|
return nil
|
|
}
|
|
func (m *mockTracingStore) GetMonthlyAgentCost(context.Context, uuid.UUID, int, time.Month) (float64, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockTracingStore) GetCostSummary(context.Context, store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockTracingStore) DeleteTracesOlderThan(context.Context, time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockTracingStore) RecoverStaleRunningTraces(context.Context, time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockTracingStore) ListCodexPoolSpans(context.Context, uuid.UUID, uuid.UUID, []string, int) ([]store.CodexPoolSpan, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockTracingStore) ListCodexPoolSpansByProviders(context.Context, uuid.UUID, []string, int) ([]store.CodexPoolProviderSpan, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func setupTraceReadToken(t *testing.T, ownerID string) string {
|
|
t.Helper()
|
|
token := "trace-read-key"
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(token): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.read"},
|
|
OwnerID: ownerID,
|
|
},
|
|
})
|
|
return token
|
|
}
|
|
|
|
func TestTracesFollowRequiresSessionOrAgent(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
tracing := &mockTracingStore{}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces/follow", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if len(tracing.listOpts) != 0 {
|
|
t.Fatalf("ListTraces called %d times, want 0", len(tracing.listOpts))
|
|
}
|
|
}
|
|
|
|
func TestTracesFollowRejectsInvalidSince(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
tracing := &mockTracingStore{}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces/follow?session_key=s1&since=not-a-time", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTracesFollowScopesViewerAndParsesCursor(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
traceID := uuid.New()
|
|
spanID := uuid.New()
|
|
createdAt := time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC)
|
|
since := time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)
|
|
tracing := &mockTracingStore{
|
|
traces: []store.TraceData{{
|
|
ID: traceID,
|
|
UserID: "caller",
|
|
SessionKey: "s1",
|
|
Status: store.TraceStatusRunning,
|
|
CreatedAt: createdAt,
|
|
}},
|
|
spans: map[uuid.UUID][]store.SpanData{
|
|
traceID: {{ID: spanID, TraceID: traceID, SpanType: store.SpanTypeEvent, Status: store.SpanStatusCompleted}},
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces/follow?session_key=s1&user_id=other&status=running&since="+since.Format(time.RFC3339)+"&limit=999&include_spans=true", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if len(tracing.listOpts) != 1 {
|
|
t.Fatalf("ListTraces calls = %d, want 1", len(tracing.listOpts))
|
|
}
|
|
opts := tracing.listOpts[0]
|
|
if opts.UserID != "caller" {
|
|
t.Fatalf("opts.UserID = %q, want caller", opts.UserID)
|
|
}
|
|
if opts.SessionKey != "s1" || opts.Status != store.TraceStatusRunning {
|
|
t.Fatalf("opts = %+v, want session/status filters", opts)
|
|
}
|
|
if opts.ChangedAfter == nil || !opts.ChangedAfter.Equal(since) {
|
|
t.Fatalf("ChangedAfter = %v, want %v", opts.ChangedAfter, since)
|
|
}
|
|
if opts.Limit != 200 {
|
|
t.Fatalf("Limit = %d, want clamp to 200", opts.Limit)
|
|
}
|
|
|
|
var body struct {
|
|
Traces []store.TraceData `json:"traces"`
|
|
SpansByTraceID map[string][]store.SpanData `json:"spans_by_trace_id"`
|
|
ServerTime string `json:"server_time"`
|
|
NextSince string `json:"next_since"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(body.Traces) != 1 || body.Traces[0].ID != traceID {
|
|
t.Fatalf("traces = %+v, want trace %s", body.Traces, traceID)
|
|
}
|
|
if got := body.SpansByTraceID[traceID.String()]; len(got) != 1 || got[0].ID != spanID {
|
|
t.Fatalf("spans_by_trace_id = %+v, want span %s", body.SpansByTraceID, spanID)
|
|
}
|
|
if body.ServerTime == "" || body.NextSince == "" {
|
|
t.Fatalf("server_time/next_since must be set: %+v", body)
|
|
}
|
|
}
|
|
|
|
func TestTracesFollowRouteIsNotCapturedAsTraceID(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
tracing := &mockTracingStore{traces: []store.TraceData{}}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces/follow?session_key=s1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
if strings.Contains(rec.Body.String(), "invalid trace") || strings.Contains(rec.Body.String(), "Invalid trace") {
|
|
t.Fatalf("follow route was captured as trace ID: status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTracesListParsesAdvancedFiltersAndScopesViewer(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
tracing := &mockTracingStore{}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces?q=abc-123&from=2026-06-10T01:02:03Z&to=2026-06-11T04:05:06Z&user_id=other&status=error&channel=telegram&agent=helper&channel_query=ops&min_input_tokens=10&max_input_tokens=20&min_output_tokens=30&max_output_tokens=40&min_tool_calls=1&max_tool_calls=3&tool_name=web_%25&has_tool_calls=true&limit=25&offset=50", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if len(tracing.listOpts) != 1 {
|
|
t.Fatalf("ListTraces calls = %d, want 1", len(tracing.listOpts))
|
|
}
|
|
opts := tracing.listOpts[0]
|
|
if opts.UserID != "caller" {
|
|
t.Fatalf("opts.UserID = %q, want caller", opts.UserID)
|
|
}
|
|
if opts.Query != "abc-123" || opts.AgentQuery != "helper" || opts.ChannelQuery != "ops" || opts.ToolName != "web_%" {
|
|
t.Fatalf("search opts = %+v, want query/agent/channel/tool filters", opts)
|
|
}
|
|
if opts.From == nil || opts.From.Format(time.RFC3339) != "2026-06-10T01:02:03Z" {
|
|
t.Fatalf("From = %v, want parsed RFC3339", opts.From)
|
|
}
|
|
if opts.To == nil || opts.To.Format(time.RFC3339) != "2026-06-11T04:05:06Z" {
|
|
t.Fatalf("To = %v, want parsed RFC3339", opts.To)
|
|
}
|
|
if opts.MinInputTokens == nil || *opts.MinInputTokens != 10 ||
|
|
opts.MaxInputTokens == nil || *opts.MaxInputTokens != 20 ||
|
|
opts.MinOutputTokens == nil || *opts.MinOutputTokens != 30 ||
|
|
opts.MaxOutputTokens == nil || *opts.MaxOutputTokens != 40 ||
|
|
opts.MinToolCalls == nil || *opts.MinToolCalls != 1 ||
|
|
opts.MaxToolCalls == nil || *opts.MaxToolCalls != 3 {
|
|
t.Fatalf("range opts = %+v, want parsed token/tool ranges", opts)
|
|
}
|
|
if opts.HasToolCalls == nil || !*opts.HasToolCalls {
|
|
t.Fatalf("HasToolCalls = %v, want true", opts.HasToolCalls)
|
|
}
|
|
if opts.Status != store.TraceStatusError || opts.Channel != "telegram" || opts.Limit != 25 || opts.Offset != 50 {
|
|
t.Fatalf("existing opts = %+v, want status/channel/pagination preserved", opts)
|
|
}
|
|
}
|
|
|
|
func TestTracesListRejectsInvalidDateRange(t *testing.T) {
|
|
token := setupTraceReadToken(t, "caller")
|
|
tracing := &mockTracingStore{}
|
|
mux := http.NewServeMux()
|
|
NewTracesHandler(tracing).RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/traces?from=not-a-time", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if len(tracing.listOpts) != 0 {
|
|
t.Fatalf("ListTraces called %d times, want 0", len(tracing.listOpts))
|
|
}
|
|
}
|