Files
tutti/services/tuttid/api/daemon_tracking_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

316 lines
9.5 KiB
Go

package api
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
reporterservice "github.com/tutti-os/tutti/services/tuttid/service/reporter"
)
type recordingAnalyticsReporter struct {
events []reporterservice.Event
closed bool
}
func (r *recordingAnalyticsReporter) Track(_ context.Context, events ...reporterservice.Event) {
r.events = append(r.events, events...)
}
func (r *recordingAnalyticsReporter) Close() error {
r.closed = true
return nil
}
func TestTrackEventsAcceptsEvents(t *testing.T) {
reporter := &recordingAnalyticsReporter{}
params := map[string]interface{}{"source": "dashboard"}
request := tuttigenerated.TrackEventsRequestObject{
Body: &tuttigenerated.TrackEventsRequest{
Events: []tuttigenerated.TrackEvent{
{
Name: "workspace.opened",
ClientTs: 1749124800000,
Params: &params,
},
{
Name: "app.pageview",
ClientTs: 1749124800010,
},
},
},
}
response, err := DaemonAPI{AnalyticsReporter: reporter}.TrackEvents(context.Background(), request)
if err != nil {
t.Fatalf("TrackEvents() error = %v", err)
}
if _, ok := response.(tuttigenerated.TrackEvents202Response); !ok {
t.Fatalf("response = %T, want %T", response, tuttigenerated.TrackEvents202Response{})
}
if len(reporter.events) != 2 {
t.Fatalf("forwarded events = %d, want 2", len(reporter.events))
}
event := reporter.events[0]
if event.Name != "workspace.opened" {
t.Fatalf("event.Name = %q, want %q", event.Name, "workspace.opened")
}
if event.ClientTS != 1749124800000 {
t.Fatalf("event.ClientTS = %d, want %d", event.ClientTS, int64(1749124800000))
}
if !reflect.DeepEqual(event.Params, map[string]any{"source": "dashboard"}) {
t.Fatalf("event.Params = %#v, want source dashboard", event.Params)
}
event.Params["source"] = "mutated"
if params["source"] != "dashboard" {
t.Fatalf("request params were mutated through reporter params: %#v", params)
}
pageviewEvent := reporter.events[1]
if pageviewEvent.Name != "app.pageview" {
t.Fatalf("pageview event.Name = %q, want %q", pageviewEvent.Name, "app.pageview")
}
if pageviewEvent.ClientTS != 1749124800010 {
t.Fatalf("pageview event.ClientTS = %d, want %d", pageviewEvent.ClientTS, int64(1749124800010))
}
if pageviewEvent.Params != nil {
t.Fatalf("pageview event.Params = %#v, want nil", pageviewEvent.Params)
}
}
func TestTrackEventsRouteRequiresNonEmptyBody(t *testing.T) {
reporter := &recordingAnalyticsReporter{}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{AnalyticsReporter: reporter}))
request := httptest.NewRequest(
http.MethodPost,
"/v1/track",
strings.NewReader(`{"events":[]}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
if !strings.Contains(response.Body.String(), "analytics_events_required") {
t.Fatalf("body does not include analytics_events_required: %s", response.Body.String())
}
if len(reporter.events) != 0 {
t.Fatalf("forwarded events = %d, want 0", len(reporter.events))
}
}
func TestTrackEventsRejectsInvalidEventShape(t *testing.T) {
validEvent := tuttigenerated.TrackEvent{
Name: "workspace.opened",
ClientTs: 1749124800000,
}
tooManyEvents := make([]tuttigenerated.TrackEvent, 101)
for i := range tooManyEvents {
tooManyEvents[i] = validEvent
}
tests := []struct {
name string
events []tuttigenerated.TrackEvent
wantReason string
}{
{
name: "over event limit",
events: tooManyEvents,
wantReason: "analytics_events_limit_exceeded",
},
{
name: "empty name",
events: []tuttigenerated.TrackEvent{
{Name: "", ClientTs: 1749124800000},
},
wantReason: "analytics_event_name_invalid",
},
{
name: "bad pattern name",
events: []tuttigenerated.TrackEvent{
{Name: "ClickWorkspaceCreate", ClientTs: 1749124800000},
},
wantReason: "analytics_event_name_invalid",
},
{
name: "legacy single segment pageview name",
events: []tuttigenerated.TrackEvent{
{Name: "predefine_pageview", ClientTs: 1749124800000},
},
wantReason: "analytics_event_name_invalid",
},
{
name: "too long name",
events: []tuttigenerated.TrackEvent{
{Name: strings.Repeat("a", 129), ClientTs: 1749124800000},
},
wantReason: "analytics_event_name_invalid",
},
{
name: "zero client timestamp",
events: []tuttigenerated.TrackEvent{
{Name: "workspace.opened", ClientTs: 0},
},
wantReason: "analytics_event_client_ts_invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reporter := &recordingAnalyticsReporter{}
request := tuttigenerated.TrackEventsRequestObject{
Body: &tuttigenerated.TrackEventsRequest{
Events: tt.events,
},
}
response, err := DaemonAPI{AnalyticsReporter: reporter}.TrackEvents(context.Background(), request)
if err != nil {
t.Fatalf("TrackEvents() error = %v", err)
}
if _, ok := response.(tuttigenerated.TrackEvents400JSONResponse); !ok {
t.Fatalf("response = %T, want %T", response, tuttigenerated.TrackEvents400JSONResponse{})
}
if !trackEventsResponseContains(t, response, tt.wantReason) {
t.Fatalf("response does not include %s", tt.wantReason)
}
if len(reporter.events) != 0 {
t.Fatalf("forwarded events = %d, want 0", len(reporter.events))
}
})
}
}
func TestTrackEventsRouteValidatesBeforeReporterAvailability(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{}))
request := httptest.NewRequest(
http.MethodPost,
"/v1/track",
strings.NewReader(`{"events":[{"client_ts":1749124800000}]}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
if !strings.Contains(response.Body.String(), "analytics_event_name_invalid") {
t.Fatalf("body does not include analytics_event_name_invalid: %s", response.Body.String())
}
}
func TestTrackEventsRouteRejectsMissingClientTimestamp(t *testing.T) {
reporter := &recordingAnalyticsReporter{}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{AnalyticsReporter: reporter}))
request := httptest.NewRequest(
http.MethodPost,
"/v1/track",
strings.NewReader(`{"events":[{"name":"workspace.opened"}]}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
if !strings.Contains(response.Body.String(), "analytics_event_client_ts_invalid") {
t.Fatalf("body does not include analytics_event_client_ts_invalid: %s", response.Body.String())
}
if len(reporter.events) != 0 {
t.Fatalf("forwarded events = %d, want 0", len(reporter.events))
}
}
func TestTrackEventsRouteRejectsNonPostMethods(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{}))
request := httptest.NewRequest(http.MethodGet, "/v1/track", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusMethodNotAllowed, response.Body.String())
}
}
func TestTrackEventsRouteRejectsInvalidJSONBody(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "empty body", body: ""},
{name: "malformed json", body: `{"events":[`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{AnalyticsReporter: &recordingAnalyticsReporter{}}))
request := httptest.NewRequest(http.MethodPost, "/v1/track", strings.NewReader(tt.body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
})
}
}
func TestTrackEventsRouteReturnsUnavailableWithoutReporter(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{}))
request := httptest.NewRequest(
http.MethodPost,
"/v1/track",
strings.NewReader(`{"events":[{"name":"workspace.opened","client_ts":1749124800000,"params":{"source":"dashboard"}}]}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusServiceUnavailable, response.Body.String())
}
if !strings.Contains(response.Body.String(), "analytics_reporter_unavailable") {
t.Fatalf("body does not include analytics_reporter_unavailable: %s", response.Body.String())
}
}
func trackEventsResponseContains(t *testing.T, response tuttigenerated.TrackEventsResponseObject, text string) bool {
t.Helper()
recorder := httptest.NewRecorder()
if err := response.VisitTrackEventsResponse(recorder); err != nil {
t.Fatalf("VisitTrackEventsResponse() error = %v", err)
}
return strings.Contains(recorder.Body.String(), text)
}