diff --git a/commands/root.go b/commands/root.go index 49178829..8a0780ed 100644 --- a/commands/root.go +++ b/commands/root.go @@ -11,6 +11,7 @@ import ( "regexp" "slices" "strings" + "time" "github.com/fatih/color" "github.com/platformsh/platformify/commands" @@ -22,6 +23,7 @@ import ( "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/config/alt" "github.com/upsun/cli/internal/legacy" + "github.com/upsun/cli/internal/telemetry" ) // Execute is the main entrypoint to run the CLI. @@ -88,11 +90,23 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob }, Run: func(cmd *cobra.Command, _ []string) { c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin()) - if err := c.Exec(cmd.Context(), os.Args[1:]...); err != nil { + err := c.Exec(cmd.Context(), os.Args[1:]...) + + if err != nil { exitWithError(err) } + + waitForTelemetry(cmd.Context(), cnf, os.Args[1:]) }, PersistentPostRun: func(cmd *cobra.Command, _ []string) { + // Send telemetry for Go-native subcommands only + // Legacy commands send telemetry in Run before exitWithError + // cmd.Parent() != nil means this is a subcommand, not the root + if cmd.Parent() != nil { + telemetry.SendTelemetryEvent(cmd.Context(), cnf, cmd.Use) + // Fire and forget for native commands + } + checkShellConfigLeftovers(cmd.ErrOrStderr(), cnf) select { case rel := <-updateMessageChan: @@ -118,9 +132,13 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob } c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin()) - if err := c.Exec(cmd.Context(), args...); err != nil { + err := c.Exec(cmd.Context(), args...) + + if err != nil { exitWithError(err) } + + // Don't send telemetry for help commands }) cmd.PersistentFlags().BoolP("version", "V", false, fmt.Sprintf("Displays the %s version", cnf.Application.Name)) @@ -276,3 +294,15 @@ func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io Stdin: stdin, } } + +func waitForTelemetry(ctx context.Context, cnf *config.Config, args []string) { + command := telemetry.ExtractCommand(args) + done := telemetry.SendTelemetryEvent(ctx, cnf, command) + + select { + case <-done: + // Telemetry completed + case <-time.After(2 * time.Second): + // Timeout - proceed anyway to avoid blocking user + } +} diff --git a/integration-tests/telemetry_test.go b/integration-tests/telemetry_test.go new file mode 100644 index 00000000..bd718dcf --- /dev/null +++ b/integration-tests/telemetry_test.go @@ -0,0 +1,399 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// telemetryEvent matches the Event struct from internal/telemetry +type telemetryEvent struct { + User string `json:"user,omitempty"` + Organization string `json:"organizationId,omitempty"` + Version string `json:"version"` + Command string `json:"command"` + Arch string `json:"arch"` + OS string `json:"os"` +} + +// getCLIVersion extracts the version from the CLI binary +func getCLIVersion(t *testing.T) string { + f := newCommandFactory(t, "", "") + output := f.Run("--version") + // Parse "Platform Test CLI 5.10.0-2026-03-24-829a9902-next" + parts := strings.Fields(output) + if len(parts) >= 4 { + return parts[3] + } + t.Fatal("Could not extract version from CLI output") + return "" +} + +// mockTelemetryServer tracks received telemetry events +type mockTelemetryServer struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + events []telemetryEvent + tokens []string +} + +func newMockTelemetryServer(t *testing.T) *mockTelemetryServer { + m := &mockTelemetryServer{ + t: t, + events: []telemetryEvent{}, + tokens: []string{}, + } + + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify it's a POST request + assert.Equal(t, http.MethodPost, r.Method) + + // Verify Content-Type + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Extract auth token if present + authHeader := r.Header.Get("Authorization") + m.mu.Lock() + if authHeader != "" { + m.tokens = append(m.tokens, authHeader) + } + m.mu.Unlock() + + // Parse the event + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var event telemetryEvent + err = json.Unmarshal(body, &event) + require.NoError(t, err, "telemetry payload must be valid JSON") + + // Store the event + m.mu.Lock() + m.events = append(m.events, event) + m.mu.Unlock() + + // Return success + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(`{"success":true}`)) + require.NoError(t, err) + })) + + return m +} + +func (m *mockTelemetryServer) Close() { + m.server.Close() +} + +func (m *mockTelemetryServer) URL() string { + return m.server.URL +} + +func (m *mockTelemetryServer) GetEvents() []telemetryEvent { + m.mu.Lock() + defer m.mu.Unlock() + // Return a copy + events := make([]telemetryEvent, len(m.events)) + copy(events, m.events) + return events +} + +func (m *mockTelemetryServer) GetTokens() []string { + m.mu.Lock() + defer m.mu.Unlock() + // Return a copy + tokens := make([]string, len(m.tokens)) + copy(tokens, m.tokens) + return tokens +} + +func (m *mockTelemetryServer) WaitForEvents(count int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + m.mu.Lock() + got := len(m.events) + m.mu.Unlock() + if got >= count { + return true + } + time.Sleep(50 * time.Millisecond) + } + return false +} + +func TestTelemetry_TrackedCommand(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "test-user-123" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ + ID: myUserID, + Username: "testuser", + }) + apiHandler.SetOrgs([]*mockapi.Org{ + { + ID: "test-org-456", + Type: "flexible", + Name: "testorg", + Label: "Test Organization", + Owner: myUserID, + }, + }) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: "test-project-1", + Organization: "test-org-456", + Vendor: "test-vendor", + Title: "Test Project", + Region: "test-region", + }, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + telemetryServer := newMockTelemetryServer(t) + defer telemetryServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = append(f.extraEnv, + EnvPrefix+"TELEMETRY_ENDPOINT="+telemetryServer.URL(), + EnvPrefix+"ORGANIZATION=testorg", + ) + + // Run a tracked command + f.Run("project:list") + + // Wait for telemetry event + require.True(t, telemetryServer.WaitForEvents(1, 3*time.Second), "telemetry event should be sent") + + events := telemetryServer.GetEvents() + require.Len(t, events, 1, "exactly one telemetry event should be sent") + + event := events[0] + assert.Equal(t, "project:list", event.Command) + assert.Equal(t, "test-user-123", event.User) + assert.Equal(t, "", event.Organization) + assert.Equal(t, getCLIVersion(t), event.Version) + assert.Equal(t, runtime.GOARCH, event.Arch) + assert.Equal(t, runtime.GOOS, event.OS) + + // Verify auth token was sent + tokens := telemetryServer.GetTokens() + require.Len(t, tokens, 1) + assert.Contains(t, tokens[0], "Bearer ") +} + +func TestTelemetry_UntrackedCommand(t *testing.T) { + telemetryServer := newMockTelemetryServer(t) + defer telemetryServer.Close() + + f := newCommandFactory(t, "", "") + f.extraEnv = append(f.extraEnv, EnvPrefix+"TELEMETRY_ENDPOINT="+telemetryServer.URL()) + + // Run an untracked command (version is not in the whitelist) + f.Run("--version") + + // Wait a bit to ensure no telemetry is sent + time.Sleep(500 * time.Millisecond) + + events := telemetryServer.GetEvents() + assert.Len(t, events, 0, "no telemetry should be sent for untracked commands") +} + +func TestTelemetry_DoNotTrack(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "test-user-123" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ + ID: myUserID, + Username: "testuser", + }) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: "test-project-1", + Organization: "test-org-456", + Vendor: "test-vendor", + Title: "Test Project", + Region: "test-region", + }, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + telemetryServer := newMockTelemetryServer(t) + defer telemetryServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = append(f.extraEnv, + EnvPrefix+"TELEMETRY_ENDPOINT="+telemetryServer.URL(), + "DO_NOT_TRACK=1", + ) + + // Run a tracked command with DO_NOT_TRACK set + f.Run("project:list") + + // Wait a bit to ensure no telemetry is sent + time.Sleep(500 * time.Millisecond) + + events := telemetryServer.GetEvents() + assert.Len(t, events, 0, "no telemetry should be sent when DO_NOT_TRACK=1") +} + +func TestTelemetry_NoEndpoint(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "test-user-123" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ + ID: myUserID, + Username: "testuser", + }) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: "test-project-1", + Organization: "test-org-456", + Vendor: "test-vendor", + Title: "Test Project", + Region: "test-region", + }, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + telemetryServer := newMockTelemetryServer(t) + defer telemetryServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + // Don't set TELEMETRY_ENDPOINT + + // Run a tracked command without endpoint configured + f.Run("project:list") + + // Wait a bit to ensure no telemetry is sent + time.Sleep(500 * time.Millisecond) + + events := telemetryServer.GetEvents() + assert.Len(t, events, 0, "no telemetry should be sent when endpoint is not configured") +} + +func TestTelemetry_MultipleCommands(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "test-user-123" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ + ID: myUserID, + Username: "testuser", + }) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: "test-project-1", + Organization: "test-org-456", + Vendor: "test-vendor", + Title: "Test Project", + Region: "test-region", + }, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + telemetryServer := newMockTelemetryServer(t) + defer telemetryServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = append(f.extraEnv, EnvPrefix+"TELEMETRY_ENDPOINT="+telemetryServer.URL()) + + // Run multiple tracked commands + f.Run("project:list") + + // Wait for first event + require.True(t, telemetryServer.WaitForEvents(1, 3*time.Second)) + + events := telemetryServer.GetEvents() + require.Len(t, events, 1) + assert.Equal(t, "project:list", events[0].Command) +} + +func TestTelemetry_ServerError(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "test-user-123" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ + ID: myUserID, + Username: "testuser", + }) + apiHandler.SetOrgs([]*mockapi.Org{ + makeOrg("org-id-1", "org-1", "Org 1", myUserID, "flexible"), + }) + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: "project-id-1", + Organization: "org-id-1", + Vendor: "test-vendor", + Title: "Test Project", + Region: "test-region", + }, + }) + apiHandler.SetUserGrants([]*mockapi.UserGrant{ + { + ResourceID: "org-id-1", + ResourceType: "organization", + OrganizationID: "org-id-1", + UserID: myUserID, + Permissions: []string{"admin"}, + }, + { + ResourceID: "project-id-1", + ResourceType: "project", + OrganizationID: "org-id-1", + UserID: myUserID, + Permissions: []string{"admin"}, + }, + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + // Create a server that returns errors + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"error":"server error"}`)) + require.NoError(t, err) + })) + defer errorServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = append(f.extraEnv, EnvPrefix+"TELEMETRY_ENDPOINT="+errorServer.URL) + + // Run a tracked command - should succeed even if telemetry fails + output := f.Run("project:list") + assert.NotEmpty(t, output, "command should succeed even if telemetry fails") +} diff --git a/internal/config/schema.go b/internal/config/schema.go index 62e012cb..5167af68 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -39,6 +39,10 @@ type Config struct { Check bool `validate:"omitempty"` // defaults to true CheckInterval int `validate:"omitempty" yaml:"check_interval,omitempty"` // seconds, defaults to 3600 } `validate:"omitempty"` + Telemetry struct { + Enabled bool `validate:"omitempty"` // defaults to true + Endpoint string `validate:"omitempty,url" yaml:"endpoint"` // telemetry endpoint + } `validate:"omitempty"` // Fields only needed by the PHP (legacy) CLI, at least for now. API struct { @@ -94,6 +98,7 @@ func (c *Config) applyDefaults() { c.Application.UserStateFile = "state.json" c.Updates.Check = true c.Updates.CheckInterval = 3600 + c.Telemetry.Enabled = true c.Service.ProjectConfigFlavor = "platform" } diff --git a/internal/telemetry/commands.go b/internal/telemetry/commands.go new file mode 100644 index 00000000..c00efc5b --- /dev/null +++ b/internal/telemetry/commands.go @@ -0,0 +1,33 @@ +package telemetry + +// trackedCommands defines which commands send telemetry. +var trackedCommands = map[string]bool{ + // Native Go commands + "init": true, + "project:init": true, + + // Important legacy commands + "project:create": true, + "environment:branch": true, + "project:delete": true, + "environment:delete": true, + "mount:upload": true, + "mount:download": true, + + // Testing commands, to be removed + "project:list": true, +} + +// IsTracked returns true if the command should send telemetry. +func IsTracked(command string) bool { + return trackedCommands[command] +} + +// ExtractCommand extracts the command name from arguments. +func ExtractCommand(args []string) string { + if len(args) == 0 { + return "unknown" + } + // Return first arg (command name, no flags) + return args[0] +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..bf593477 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,197 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "runtime" + "strings" + "time" + + "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/legacy" +) + +const defaultTimeout = 2 * time.Second + +// Event represents a generic telemetry event payload. +type Event struct { + User string `json:"user,omitempty"` + Organization string `json:"organizationId,omitempty"` + Version string `json:"version"` + Command string `json:"command"` + Arch string `json:"arch"` + OS string `json:"os"` +} + +// SendTelemetryEvent sends a telemetry event asynchronously. +// It respects the DO_NOT_TRACK environment variable and fails silently on errors. +// Returns a channel that will be closed when the telemetry operation completes. +func SendTelemetryEvent(ctx context.Context, cnf *config.Config, command string) chan struct{} { + done := make(chan struct{}) + + // Respect DO_NOT_TRACK + if os.Getenv("DO_NOT_TRACK") == "1" { + close(done) + return done + } + + // Check if telemetry is enabled in config + if !cnf.Telemetry.Enabled { + close(done) + return done + } + + // Check if command is whitelisted + if !IsTracked(command) { + close(done) + return done + } + + // Get endpoint from config/environment + endpoint := getEndpoint(cnf) + if endpoint == "" { + // Silently skip if no endpoint is configured + close(done) + return done + } + + // Run in a goroutine to avoid blocking + go func() { + defer close(done) + + ctx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + // Create legacy wrapper to fetch user/org IDs and auth token + wrapper := makeLegacyCLIWrapper(cnf) + userID := getUserID(ctx, wrapper) + orgID := getOrgID(ctx, wrapper) + authToken := getAuthToken(ctx, wrapper) + + // Build the event + event := &Event{ + User: userID, + Organization: orgID, + Version: config.Version, + Command: command, + Arch: runtime.GOARCH, + OS: runtime.GOOS, + } + + // Send the event + if err := sendEvent(ctx, endpoint, event, authToken); err != nil { + // Fail silently - telemetry should never interfere with user experience + return + } + }() + + return done +} + +// sendEvent sends the event to the configured telemetry endpoint. +func sendEvent(ctx context.Context, endpoint string, event *Event, authToken string) error { + // Marshal the event + payload, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Create the request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + + // Add authorization header using CLI's auth token + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check status code (but don't fail on non-2xx since we fail silently anyway) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// getEndpoint returns the telemetry endpoint from config or environment. +func getEndpoint(cnf *config.Config) string { + // Try environment variable first + if endpoint := os.Getenv(cnf.Application.EnvPrefix + "TELEMETRY_ENDPOINT"); endpoint != "" { + return endpoint + } + + // Fall back to config + return cnf.Telemetry.Endpoint +} + +// makeLegacyCLIWrapper creates a legacy CLI wrapper for executing commands. +func makeLegacyCLIWrapper(cnf *config.Config) *legacy.CLIWrapper { + return &legacy.CLIWrapper{ + Config: cnf, + Version: config.Version, + DisableInteraction: true, + // No stdout/stderr/stdin - we'll override these per call + } +} + +// getUserID retrieves the user ID from the legacy CLI. +// Returns empty string if not authenticated or on error. +func getUserID(ctx context.Context, wrapper *legacy.CLIWrapper) string { + var buf bytes.Buffer + wrapper.Stdout = &buf + wrapper.Stderr = nil + wrapper.Stdin = nil + + if err := wrapper.Exec(ctx, "auth:info", "-P", "id", "--no-interaction"); err != nil { + return "" // Return empty if not authenticated + } + + return strings.TrimSpace(buf.String()) +} + +// getOrgID retrieves the organization ID from the legacy CLI. +// Returns empty string if no org context or on error. +func getOrgID(ctx context.Context, wrapper *legacy.CLIWrapper) string { + var buf bytes.Buffer + wrapper.Stdout = &buf + wrapper.Stderr = nil + wrapper.Stdin = nil + + if err := wrapper.Exec(ctx, "organization:info", "id", "--no-interaction"); err != nil { + return "" // Return empty if no org context + } + + return strings.TrimSpace(buf.String()) +} + +// getAuthToken retrieves the authentication token from the legacy CLI. +// Returns empty string if not authenticated or on error. +func getAuthToken(ctx context.Context, wrapper *legacy.CLIWrapper) string { + var buf bytes.Buffer + wrapper.Stdout = &buf + wrapper.Stderr = nil + wrapper.Stdin = nil + + if err := wrapper.Exec(ctx, "auth:token", "-W"); err != nil { + return "" // Return empty if not authenticated + } + + return strings.TrimSpace(buf.String()) +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..c2f10a42 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,157 @@ +package telemetry + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/config" +) + +func TestSendTelemetryEvent_RespectsDoNotTrack(_ *testing.T) { + originalValue := os.Getenv("DO_NOT_TRACK") + os.Setenv("DO_NOT_TRACK", "1") + defer func() { + if originalValue == "" { + os.Unsetenv("DO_NOT_TRACK") + } else { + os.Setenv("DO_NOT_TRACK", originalValue) + } + }() + + cnf := &config.Config{} + cnf.Telemetry.Enabled = true + cnf.Telemetry.Endpoint = "http://localhost:8080" + + done := SendTelemetryEvent(context.Background(), cnf, "init") + <-done // Should complete immediately without sending +} + +func TestSendTelemetryEvent_RespectsDisabledConfig(_ *testing.T) { + originalValue := os.Getenv("DO_NOT_TRACK") + os.Unsetenv("DO_NOT_TRACK") + defer func() { + if originalValue != "" { + os.Setenv("DO_NOT_TRACK", originalValue) + } + }() + + cnf := &config.Config{} + cnf.Telemetry.Enabled = false + cnf.Telemetry.Endpoint = "http://localhost:8080" + + done := SendTelemetryEvent(context.Background(), cnf, "init") + <-done // Should complete immediately without sending +} + +func TestSendTelemetryEvent_RequiresEndpoint(_ *testing.T) { + cnf := &config.Config{} + cnf.Telemetry.Enabled = true + cnf.Telemetry.Endpoint = "" + + done := SendTelemetryEvent(context.Background(), cnf, "init") + <-done // Should skip telemetry +} + +func TestSendTelemetryEvent_OnlyTrackedCommands(t *testing.T) { + cnf := &config.Config{} + cnf.Telemetry.Enabled = true + cnf.Telemetry.Endpoint = "http://localhost:8080" + + // Tracked command + assert.True(t, IsTracked("init")) + + // Non-tracked command + assert.False(t, IsTracked("version")) + + // Non-tracked command should complete immediately + done := SendTelemetryEvent(context.Background(), cnf, "version") + <-done +} + +func TestIsTracked(t *testing.T) { + cases := []struct { + command string + tracked bool + }{ + {"init", true}, + {"project:init", true}, + {"project:create", true}, + {"environment:branch", true}, + {"project:delete", true}, + {"environment:delete", true}, + {"mount:upload", true}, + {"mount:download", true}, + {"version", false}, + {"list", false}, + {"help", false}, + {"unknown", false}, + } + + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + assert.Equal(t, tc.tracked, IsTracked(tc.command)) + }) + } +} + +func TestExtractCommand(t *testing.T) { + cases := []struct { + args []string + expected string + }{ + {[]string{"init"}, "init"}, + {[]string{"project:create", "--flag"}, "project:create"}, + {[]string{"environment:branch", "new-branch"}, "environment:branch"}, + {[]string{}, "unknown"}, + } + + for _, tc := range cases { + t.Run(tc.expected, func(t *testing.T) { + assert.Equal(t, tc.expected, ExtractCommand(tc.args)) + }) + } +} + +func TestBuildEvent(t *testing.T) { + event := &Event{ + User: "user-123", + Organization: "org-456", + Version: "1.0.0", + Command: "init", + Arch: "arm64", + OS: "darwin", + } + + payload, err := json.Marshal(event) + assert.NoError(t, err) + assert.NotEmpty(t, payload) + + // Verify JSON structure + var decoded map[string]any + err = json.Unmarshal(payload, &decoded) + assert.NoError(t, err) + assert.Equal(t, "user-123", decoded["user"]) + assert.Equal(t, "org-456", decoded["organizationId"]) + assert.Equal(t, "1.0.0", decoded["version"]) + assert.Equal(t, "init", decoded["command"]) + assert.Equal(t, "arm64", decoded["arch"]) + assert.Equal(t, "darwin", decoded["os"]) +} + +func TestGetEndpoint(t *testing.T) { + cnf := &config.Config{} + cnf.Application.EnvPrefix = "TEST_CLI_" + cnf.Telemetry.Endpoint = "http://config-endpoint.com" + + // Test config value + assert.Equal(t, "http://config-endpoint.com", getEndpoint(cnf)) + + // Test environment variable override + os.Setenv("TEST_CLI_TELEMETRY_ENDPOINT", "http://env-endpoint.com") + defer os.Unsetenv("TEST_CLI_TELEMETRY_ENDPOINT") + assert.Equal(t, "http://env-endpoint.com", getEndpoint(cnf)) +}