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
316 lines
9.5 KiB
Go
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: ¶ms,
|
|
},
|
|
{
|
|
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)
|
|
}
|