diff --git a/cmd/events.go b/cmd/events.go new file mode 100644 index 0000000..f523905 --- /dev/null +++ b/cmd/events.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/cmdutil" + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +func parseMailingLists(pairs []string) (map[string]bool, error) { + if len(pairs) == 0 { + return nil, nil + } + m := make(map[string]bool, len(pairs)) + for _, pair := range pairs { + idx := strings.IndexByte(pair, '=') + if idx < 0 { + return nil, fmt.Errorf("--list %q: expected id=true|false", pair) + } + id := pair[:idx] + val := strings.ToLower(pair[idx+1:]) + switch val { + case "true": + m[id] = true + case "false": + m[id] = false + default: + return nil, fmt.Errorf("--list %q: value must be \"true\" or \"false\"", pair) + } + } + return m, nil +} + +func runEventsSend(cfg *config.Config, req api.SendEventRequest) error { + return api.NewClient(cfg.EndpointURL, cfg.APIKey).SendEvent(req) +} + +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "Manage events", +} + +var eventsSendCmd = &cobra.Command{ + Use: "send", + Short: "Send an event", + RunE: eventsSendRunE, +} + +func eventsSendRunE(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + email, _ := cmd.Flags().GetString("email") + userID, _ := cmd.Flags().GetString("user-id") + if email == "" && userID == "" { + return fmt.Errorf("at least one of --email or --user-id is required") + } + + eventName, _ := cmd.Flags().GetString("event") + idempotencyKey, _ := cmd.Flags().GetString("idempotency-key") + + req := api.SendEventRequest{ + Email: email, + UserID: userID, + EventName: eventName, + IdempotencyKey: idempotencyKey, + } + + if propsPath, _ := cmd.Flags().GetString("props"); propsPath != "" { + props, err := cmdutil.ParseJSONFile("props", propsPath) + if err != nil { + return err + } + if nested, ok := props["eventProperties"]; ok { + if m, ok := nested.(map[string]any); ok { + req.EventProperties = m + } + } else { + req.EventProperties = props + } + } + + if contactPropsPath, _ := cmd.Flags().GetString("contact-props"); contactPropsPath != "" { + contactProps, err := cmdutil.ParseJSONFile("contact-props", contactPropsPath) + if err != nil { + return err + } + req.ContactProperties = contactProps + } + + listPairs, _ := cmd.Flags().GetStringArray("list") + mailingLists, err := parseMailingLists(listPairs) + if err != nil { + return err + } + req.MailingLists = mailingLists + + if err := runEventsSend(cfg, req); err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), Result{Success: true}) + } + fmt.Fprintln(cmd.OutOrStdout(), "Sent.") + return nil +} + +func addEventsSendFlags(cmd *cobra.Command) { + cmd.Flags().String("event", "", "Event name") + cmd.Flags().String("email", "", "Contact email address") + cmd.Flags().String("user-id", "", "Contact user ID") + cmd.Flags().String("props", "", "Path to a JSON file of event properties") + cmd.Flags().String("contact-props", "", "Path to a JSON file of contact properties") + cmd.Flags().StringArray("list", nil, "Mailing list subscription as id=true|false (repeatable)") + cmd.Flags().String("idempotency-key", "", "Idempotency key to prevent duplicate sends") + cmd.MarkFlagRequired("event") +} + +func init() { + addEventsSendFlags(eventsSendCmd) + eventsCmd.AddCommand(eventsSendCmd) + rootCmd.AddCommand(eventsCmd) +} diff --git a/cmd/events_test.go b/cmd/events_test.go new file mode 100644 index 0000000..caab286 --- /dev/null +++ b/cmd/events_test.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/cmdutil" + "github.com/zalando/go-keyring" +) + +func TestParseMailingLists(t *testing.T) { + t.Run("valid true and false", func(t *testing.T) { + m, err := parseMailingLists([]string{"abc=true", "def=false"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m["abc"] != true { + t.Errorf("expected abc=true, got %v", m["abc"]) + } + if m["def"] != false { + t.Errorf("expected def=false, got %v", m["def"]) + } + }) + + t.Run("case-insensitive", func(t *testing.T) { + m, err := parseMailingLists([]string{"abc=True", "def=FALSE"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m["abc"] != true { + t.Errorf("expected abc=true, got %v", m["abc"]) + } + if m["def"] != false { + t.Errorf("expected def=false, got %v", m["def"]) + } + }) + + t.Run("missing equals", func(t *testing.T) { + _, err := parseMailingLists([]string{"badvalue"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "expected id=true|false") { + t.Errorf("error %q should mention expected id=true|false", err.Error()) + } + }) + + t.Run("invalid value", func(t *testing.T) { + _, err := parseMailingLists([]string{"abc=maybe"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), `value must be "true" or "false"`) { + t.Errorf("error %q should mention value must be true or false", err.Error()) + } + }) + + t.Run("empty returns nil", func(t *testing.T) { + m, err := parseMailingLists(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m != nil { + t.Errorf("expected nil map, got %v", m) + } + }) +} + +func serveEventsSend(t *testing.T, status int, body string, check func(*http.Request)) { + t.Helper() + keyring.MockInit() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if check != nil { + check(r) + } + w.WriteHeader(status) + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("LOOPS_API_KEY", "test-key") + t.Setenv("LOOPS_ENDPOINT_URL", srv.URL) +} + +func TestEventsSend(t *testing.T) { + t.Run("happy path with email", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"success":true}`) + err := runEventsSend(cfg(t), api.SendEventRequest{ + Email: "user@example.com", + EventName: "user-signed-up", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("happy path with userId", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"success":true}`) + err := runEventsSend(cfg(t), api.SendEventRequest{ + UserID: "user-123", + EventName: "user-signed-up", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("api error", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"error":"invalid event"}`) + err := runEventsSend(cfg(t), api.SendEventRequest{ + Email: "user@example.com", + EventName: "bad-event", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid event") { + t.Errorf("error %q should mention invalid event", err.Error()) + } + }) + + t.Run("missing both email and userId returns validation error", func(t *testing.T) { + email := "" + userID := "" + if email == "" && userID == "" { + err := fmt.Errorf("at least one of --email or --user-id is required") + if !strings.Contains(err.Error(), "--email") { + t.Errorf("error should mention --email") + } + } + }) + + t.Run("props valid JSON", func(t *testing.T) { + var captured map[string]any + serveEventsSend(t, http.StatusOK, `{"success":true}`, func(r *http.Request) { + json.NewDecoder(r.Body).Decode(&captured) + }) + f, _ := os.CreateTemp(t.TempDir(), "props-*.json") + json.NewEncoder(f).Encode(map[string]any{"plan": "pro", "trial": true}) + f.Close() + + props, err := cmdutil.ParseJSONFile("props", f.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if props["plan"] != "pro" { + t.Errorf("expected plan=pro, got %v", props["plan"]) + } + }) + + t.Run("props file not found", func(t *testing.T) { + _, err := cmdutil.ParseJSONFile("props", "/nonexistent/props.json") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--props") { + t.Errorf("error %q should mention --props", err.Error()) + } + }) + + t.Run("props invalid JSON", func(t *testing.T) { + f, _ := os.CreateTemp(t.TempDir(), "props-*.json") + f.WriteString("not json") + f.Close() + + _, err := cmdutil.ParseJSONFile("props", f.Name()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--props must be a valid JSON object") { + t.Errorf("error %q should mention --props must be a valid JSON object", err.Error()) + } + }) + + t.Run("contact-props merged at top level", func(t *testing.T) { + var captured map[string]any + serveEventsSend(t, http.StatusOK, `{"success":true}`, func(r *http.Request) { + json.NewDecoder(r.Body).Decode(&captured) + }) + + f, _ := os.CreateTemp(t.TempDir(), "contact-*.json") + json.NewEncoder(f).Encode(map[string]any{"firstName": "Alice", "age": float64(30)}) + f.Close() + + contactProps, err := cmdutil.ParseJSONFile("contact-props", f.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = runEventsSend(cfg(t), api.SendEventRequest{ + Email: "user@example.com", + EventName: "upgrade", + ContactProperties: contactProps, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if captured["firstName"] != "Alice" { + t.Errorf("expected firstName=Alice in body, got %v", captured["firstName"]) + } + }) + + t.Run("list single and multiple", func(t *testing.T) { + m, err := parseMailingLists([]string{"list1=true", "list2=false"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m["list1"] != true || m["list2"] != false { + t.Errorf("unexpected map: %v", m) + } + }) + + t.Run("list invalid value", func(t *testing.T) { + _, err := parseMailingLists([]string{"list1=maybe"}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("idempotency-key forwarded as header", func(t *testing.T) { + var capturedKey string + serveEventsSend(t, http.StatusOK, `{"success":true}`, func(r *http.Request) { + capturedKey = r.Header.Get("Idempotency-Key") + }) + err := runEventsSend(cfg(t), api.SendEventRequest{ + Email: "user@example.com", + EventName: "test", + IdempotencyKey: "my-key-123", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedKey != "my-key-123" { + t.Errorf("expected Idempotency-Key=my-key-123, got %q", capturedKey) + } + }) + + t.Run("json output mode", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"success":true}`) + var buf bytes.Buffer + t.Setenv("OUTPUT_FORMAT", "json") + + err := runEventsSend(cfg(t), api.SendEventRequest{ + Email: "user@example.com", + EventName: "test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _ = buf + }) + + t.Run("props with eventProperties wrapper", func(t *testing.T) { + f, _ := os.CreateTemp(t.TempDir(), "props-*.json") + json.NewEncoder(f).Encode(map[string]any{ + "eventProperties": map[string]any{"plan": "pro", "trial": true}, + }) + f.Close() + + props, err := cmdutil.ParseJSONFile("props", f.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var eventProps map[string]any + if nested, ok := props["eventProperties"]; ok { + if m, ok := nested.(map[string]any); ok { + eventProps = m + } + } else { + eventProps = props + } + + if eventProps["plan"] != "pro" { + t.Errorf("expected plan=pro, got %v", eventProps["plan"]) + } + }) +} diff --git a/cmd/transactional.go b/cmd/transactional.go index c65e1c7..4cb2e80 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/base64" - "encoding/json" "fmt" "net/http" "os" @@ -10,6 +9,7 @@ import ( "strings" "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/cmdutil" "github.com/loops-so/cli/internal/config" "github.com/spf13/cobra" ) @@ -17,12 +17,10 @@ import ( func parseDataVars(vars []string, jsonFile string) (map[string]any, error) { var m map[string]any if jsonFile != "" { - data, err := os.ReadFile(jsonFile) + var err error + m, err = cmdutil.ParseJSONFile("json-vars", jsonFile) if err != nil { - return nil, fmt.Errorf("--json-vars: %w", err) - } - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("--json-vars must be a valid JSON object: %w", err) + return nil, err } } for _, pair := range vars { diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..a5cecce --- /dev/null +++ b/internal/api/events.go @@ -0,0 +1,63 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type SendEventRequest struct { + Email string `json:"-"` + UserID string `json:"-"` + EventName string `json:"-"` + EventProperties map[string]any `json:"-"` + MailingLists map[string]bool `json:"-"` + ContactProperties map[string]any `json:"-"` + IdempotencyKey string `json:"-"` +} + +func (c *Client) SendEvent(req SendEventRequest) error { + body := make(map[string]any) + for k, v := range req.ContactProperties { + body[k] = v + } + body["eventName"] = req.EventName + if req.Email != "" { + body["email"] = req.Email + } + if req.UserID != "" { + body["userId"] = req.UserID + } + if len(req.EventProperties) > 0 { + body["eventProperties"] = req.EventProperties + } + if len(req.MailingLists) > 0 { + body["mailingLists"] = req.MailingLists + } + + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + + httpReq, err := c.newRequest(http.MethodPost, "/events/send", bytes.NewReader(b)) + if err != nil { + return err + } + if req.IdempotencyKey != "" { + httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey) + } + + resp, err := c.do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errorFromResponse(resp) + } + + return nil +} diff --git a/internal/api/events_test.go b/internal/api/events_test.go new file mode 100644 index 0000000..2b2fce6 --- /dev/null +++ b/internal/api/events_test.go @@ -0,0 +1,276 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendEvent(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantAPIErr *APIError + }{ + { + name: "success", + statusCode: http.StatusOK, + body: `{"success":true}`, + }, + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + body: `{"message":"Invalid API key"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid API key"}, + }, + { + name: "not found", + statusCode: http.StatusNotFound, + body: `{"message":"Event not found"}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Event not found"}, + }, + { + name: "bad request", + statusCode: http.StatusBadRequest, + body: `{"message":"Email is required"}`, + wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Email is required"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + err := client.SendEvent(SendEventRequest{ + Email: "test@example.com", + EventName: "signup", + }) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if tt.wantAPIErr.Message != "" && apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestSendEvent_RequestBody(t *testing.T) { + tests := []struct { + name string + req SendEventRequest + wantPresent map[string]any + wantAbsent []string + }{ + { + name: "email only", + req: SendEventRequest{Email: "a@b.com", EventName: "click"}, + wantPresent: map[string]any{ + "email": "a@b.com", + "eventName": "click", + }, + wantAbsent: []string{"userId"}, + }, + { + name: "userId only", + req: SendEventRequest{UserID: "user-123", EventName: "click"}, + wantPresent: map[string]any{ + "userId": "user-123", + "eventName": "click", + }, + wantAbsent: []string{"email"}, + }, + { + name: "eventProperties", + req: SendEventRequest{ + Email: "a@b.com", + EventName: "purchase", + EventProperties: map[string]any{"amount": 42.0, "plan": "pro"}, + }, + wantPresent: map[string]any{ + "eventName": "purchase", + }, + }, + { + name: "mailingLists", + req: SendEventRequest{ + Email: "a@b.com", + EventName: "signup", + MailingLists: map[string]bool{"list-abc": true, "list-def": false}, + }, + wantPresent: map[string]any{ + "eventName": "signup", + }, + }, + { + name: "contact props merged at top level", + req: SendEventRequest{ + Email: "a@b.com", + EventName: "signup", + ContactProperties: map[string]any{"firstName": "Alice", "plan": "starter"}, + }, + wantPresent: map[string]any{ + "firstName": "Alice", + "plan": "starter", + }, + }, + { + name: "contact props do not override named fields", + req: SendEventRequest{ + Email: "a@b.com", + EventName: "signup", + ContactProperties: map[string]any{"email": "override@b.com"}, + }, + wantPresent: map[string]any{ + "email": "a@b.com", + }, + }, + { + name: "eventProperties omitted when nil", + req: SendEventRequest{Email: "a@b.com", EventName: "click"}, + wantAbsent: []string{"eventProperties"}, + }, + { + name: "mailingLists omitted when nil", + req: SendEventRequest{Email: "a@b.com", EventName: "click"}, + wantAbsent: []string{"mailingLists"}, + }, + { + name: "eventName always present", + req: SendEventRequest{Email: "a@b.com", EventName: "my-event"}, + wantPresent: map[string]any{ + "eventName": "my-event", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + client.SendEvent(tt.req) + + for key, want := range tt.wantPresent { + got, ok := body[key] + if !ok { + t.Errorf("key %q missing from body", key) + continue + } + if want != nil && got != want { + t.Errorf("body[%q] = %v, want %v", key, got, want) + } + } + + for _, key := range tt.wantAbsent { + if _, ok := body[key]; ok { + t.Errorf("key %q should be absent from body", key) + } + } + + // verify eventProperties structure when set + if tt.req.EventProperties != nil { + ep, ok := body["eventProperties"].(map[string]any) + if !ok { + t.Errorf("eventProperties is not a map: %T", body["eventProperties"]) + } else { + for k, v := range tt.req.EventProperties { + if ep[k] != v { + t.Errorf("eventProperties[%q] = %v, want %v", k, ep[k], v) + } + } + } + } + + // verify mailingLists structure when set + if tt.req.MailingLists != nil { + ml, ok := body["mailingLists"].(map[string]any) + if !ok { + t.Errorf("mailingLists is not a map: %T", body["mailingLists"]) + } else { + for k, v := range tt.req.MailingLists { + if ml[k] != v { + t.Errorf("mailingLists[%q] = %v, want %v", k, ml[k], v) + } + } + } + } + }) + } +} + +func TestSendEvent_IdempotencyKey(t *testing.T) { + tests := []struct { + name string + idempotencyKey string + wantHeader string + }{ + { + name: "sets header when provided", + idempotencyKey: "my-key-123", + wantHeader: "my-key-123", + }, + { + name: "omits header when empty", + idempotencyKey: "", + wantHeader: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotHeader string + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("Idempotency-Key") + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + client.SendEvent(SendEventRequest{ + Email: "a@b.com", + EventName: "click", + IdempotencyKey: tt.idempotencyKey, + }) + + if gotHeader != tt.wantHeader { + t.Errorf("Idempotency-Key header = %q, want %q", gotHeader, tt.wantHeader) + } + if _, ok := body["idempotencyKey"]; ok { + t.Error("idempotencyKey should not appear in request body") + } + }) + } +} diff --git a/internal/cmdutil/parse.go b/internal/cmdutil/parse.go new file mode 100644 index 0000000..7d82573 --- /dev/null +++ b/internal/cmdutil/parse.go @@ -0,0 +1,19 @@ +package cmdutil + +import ( + "encoding/json" + "fmt" + "os" +) + +func ParseJSONFile(flag, path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("--%s: %w", flag, err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("--%s must be a valid JSON object: %w", flag, err) + } + return m, nil +}