diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0bef787..383a79b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -67,3 +67,30 @@ jobs: # uses: codecov/codecov-action@v4 # with: # token: ${{ secrets.CODECOV_TOKEN }} + + e2e-test: + name: E2E Test + runs-on: ubuntu-latest + needs: test + steps: + # step 1: checkout repository code + - name: Checkout code into workspace directory + uses: actions/checkout@v4 + + # step 2: set up go + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + # step 3: install main project dependencies + - name: Install main Go dependencies + run: go mod download + + # step 4: install e2e test dependencies + - name: Install E2E test dependencies + run: cd tests/e2e && go mod download + + # step 5: run e2e tests with verbose output + - name: Run E2E tests + run: cd tests/e2e && go test -v ./... diff --git a/internal/commands/webhooks/register.go b/internal/commands/webhooks/register.go index db0eb69..5053d3b 100644 --- a/internal/commands/webhooks/register.go +++ b/internal/commands/webhooks/register.go @@ -2,6 +2,7 @@ package webhooks import ( "fmt" + "net/url" "strings" "github.com/android-sms-gateway/cli/internal/core/codes" @@ -41,17 +42,23 @@ var register = &cli.Command{ }, }, Action: func(c *cli.Context) error { - url := c.Args().Get(0) - if url == "" { + targetUrl := strings.TrimSpace(c.Args().Get(0)) + if targetUrl == "" { return cli.Exit("URL is empty", codes.ParamsError) } + // accept only absolute http/https URLs + parsed, err := url.Parse(targetUrl) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return cli.Exit("invalid URL", codes.ParamsError) + } + client := metadata.GetClient(c.App.Metadata) renderer := metadata.GetRenderer(c.App.Metadata) req := smsgateway.Webhook{ ID: c.String("id"), - URL: url, + URL: targetUrl, Event: c.String("event"), } diff --git a/tests/e2e/general_test.go b/tests/e2e/general_test.go new file mode 100644 index 0000000..6b8d70b --- /dev/null +++ b/tests/e2e/general_test.go @@ -0,0 +1,25 @@ +package e2e + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHelpFlag(t *testing.T) { + // Run the CLI binary with the --help flag + var stdout, stderr bytes.Buffer + + cmd := exec.Command("./smsgate", "--help") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + assert.NoError(t, err) + + // Verify the output + assert.Contains(t, stdout.String(), "CLI interface for working with SMS Gateway for Androidâ„¢") +} diff --git a/tests/e2e/go.mod b/tests/e2e/go.mod new file mode 100644 index 0000000..4f63d9b --- /dev/null +++ b/tests/e2e/go.mod @@ -0,0 +1,11 @@ +module e2e + +go 1.24.3 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/e2e/go.sum b/tests/e2e/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/tests/e2e/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/e2e/main_test.go b/tests/e2e/main_test.go new file mode 100644 index 0000000..b9fa8b9 --- /dev/null +++ b/tests/e2e/main_test.go @@ -0,0 +1,23 @@ +package e2e + +import ( + "os" + "os/exec" + "testing" +) + +func TestMain(m *testing.M) { + // Build the CLI binary + cmd := exec.Command("go", "build", "-o", "tests/e2e/smsgate", "cmd/smsgate/smsgate.go") + cmd.Dir = "../../" + err := cmd.Run() + if err != nil { + panic(err) + } + + code := m.Run() + + _ = os.Remove("smsgate") + + os.Exit(code) +} diff --git a/tests/e2e/messages_test.go b/tests/e2e/messages_test.go new file mode 100644 index 0000000..3fe6b9e --- /dev/null +++ b/tests/e2e/messages_test.go @@ -0,0 +1,468 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + "e2e/testutils" + + "github.com/stretchr/testify/assert" +) + +func TestMessageSendValid(t *testing.T) { + tests := []struct { + name string + message string + phones []string + deviceID string + simNumber int + priority int + dataMessage bool + dataPort uint + expectJSON bool + expectedStatus int + }{ + { + name: "send SMS with single recipient", + message: "Hello, this is a test message", + phones: []string{"+12025550123"}, + deviceID: "", + simNumber: 0, + priority: 0, + dataMessage: false, + dataPort: 0, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + { + name: "send SMS with multiple recipients", + message: "Hello everyone!", + phones: []string{"+12025550123", "+12025550124", "+12025550125"}, + deviceID: "", + simNumber: 0, + priority: 0, + dataMessage: false, + dataPort: 0, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + { + name: "send SMS with device ID", + message: "Device specific message", + phones: []string{"+12025550123"}, + deviceID: "device-123", + simNumber: 0, + priority: 0, + dataMessage: false, + dataPort: 0, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + { + name: "send SMS with SIM number", + message: "SIM specific message", + phones: []string{"+12025550123"}, + deviceID: "", + simNumber: 1, + priority: 0, + dataMessage: false, + dataPort: 0, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + { + name: "send SMS with high priority", + message: "High priority message", + phones: []string{"+12025550123"}, + deviceID: "", + simNumber: 0, + priority: 100, + dataMessage: false, + dataPort: 0, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + { + name: "send data message", + message: "SGVsbG8gV29ybGQh", // "Hello World!" in base64 + phones: []string{"+12025550123"}, + deviceID: "", + simNumber: 0, + priority: 0, + dataMessage: true, + dataPort: 8080, + expectJSON: false, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/messages", r.URL.Path) + + var req map[string]any + err := json.NewDecoder(r.Body).Decode(&req) + assert.NoError(t, err) + + // Validate request structure - convert interface{} to []string + phoneNumbers := req["phoneNumbers"].([]any) + expectedPhones := make([]any, len(tt.phones)) + for i, phone := range tt.phones { + expectedPhones[i] = phone + } + assert.Equal(t, expectedPhones, phoneNumbers) + if tt.deviceID != "" { + assert.Equal(t, tt.deviceID, req["deviceId"]) + } + if tt.simNumber > 0 { + assert.Equal(t, float64(tt.simNumber), req["simNumber"]) + } + if tt.priority != 0 { + assert.Equal(t, float64(tt.priority), req["priority"]) + } + if tt.dataMessage { + assert.NotNil(t, req["dataMessage"]) + dataMsg := req["dataMessage"].(map[string]interface{}) + assert.Equal(t, tt.message, dataMsg["data"]) + assert.Equal(t, float64(tt.dataPort), dataMsg["port"]) + } else { + assert.NotNil(t, req["textMessage"]) + textMsg := req["textMessage"].(map[string]interface{}) + assert.Equal(t, tt.message, textMsg["text"]) + } + + response := map[string]interface{}{ + "id": fmt.Sprintf("msg-%d", time.Now().Unix()), + "state": "Pending", + "createdAt": time.Now().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.expectedStatus) + json.NewEncoder(w).Encode(response) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "send", + "--phones", strings.Join(tt.phones, ","), + } + + if tt.deviceID != "" { + args = append(args, "--device-id", tt.deviceID) + } + if tt.simNumber > 0 { + args = append(args, "--sim-number", fmt.Sprintf("%d", tt.simNumber)) + } + if tt.priority != 0 { + args = append(args, "--priority", fmt.Sprintf("%d", tt.priority)) + } + if tt.dataMessage { + args = append(args, "--data", "--data-port", fmt.Sprintf("%d", tt.dataPort)) + } + + args = append(args, tt.message) + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.NoError(t, err, "stderr: %s", stderr.String()) + + if tt.expectJSON { + var response map[string]interface{} + err := json.Unmarshal(stdout.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response["id"], "msg-") + } + }) + } +} + +func TestMessageSendInvalid(t *testing.T) { + tests := []struct { + name string + message string + phones []string + extraArgs []string + expectedErr string + setupStatus int + }{ + { + name: "missing message content", + message: "", + phones: []string{"+12025550123"}, + expectedErr: "Message is empty", + setupStatus: http.StatusBadRequest, + }, + { + name: "missing phone numbers", + message: "Hello, world!", + phones: []string{}, + expectedErr: "Required flag \"phones\" not set", + setupStatus: http.StatusBadRequest, + }, + { + name: "invalid phone format", + message: "Hello, world!", + phones: []string{"invalid-phone"}, + expectedErr: "validation failed", + setupStatus: http.StatusBadRequest, + }, + { + name: "invalid SIM number", + message: "Hello, world!", + phones: []string{"+12025550123"}, + extraArgs: []string{"--sim-number", "bad-sim"}, + expectedErr: "invalid value \"bad-sim\" for flag -sim-number", + setupStatus: http.StatusBadRequest, + }, + { + name: "invalid priority", + message: "Hello, world!", + phones: []string{"+12025550123"}, + extraArgs: []string{"--priority", "9999"}, + expectedErr: "Priority must be between -128 and 127", + setupStatus: http.StatusBadRequest, + }, + { + name: "invalid base64 data", + message: "not-base64", + phones: []string{"+12025550123"}, + extraArgs: []string{"--data"}, + expectedErr: "Invalid base64 data", + setupStatus: http.StatusBadRequest, + }, + { + name: "invalid data port", + message: "SGVsbG8=", // valid base64 + phones: []string{"+12025550123"}, + extraArgs: []string{"--data", "--data-port", "99999"}, + expectedErr: "Data port must be between 1 and 65535", + setupStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.setupStatus) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{"send"} + + if len(tt.phones) > 0 { + args = append(args, "--phones", strings.Join(tt.phones, ",")) + } + + if len(tt.extraArgs) > 0 { + args = append(args, tt.extraArgs...) + } + + args = append(args, tt.message) + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, stderr.String(), tt.expectedErr) + }) + } +} + +func TestMessageSendAuthenticationError(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "Invalid credentials"}`)) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "send", + "--phones", "+12025550123", + "Hello, world!", + } + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=wronguser", "ASG_PASSWORD=wrongpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, stderr.String(), "Invalid credentials") +} + +func TestMessageStatusValid(t *testing.T) { + tests := []struct { + name string + messageID string + setupStatus int + setupBody string + expectJSON bool + }{ + { + name: "get existing message status", + messageID: "msg-12345", + setupStatus: http.StatusOK, + setupBody: `{"id": "msg-12345", "state": "Delivered", "createdAt": "2023-01-01T00:00:00Z"}`, + expectJSON: false, + }, + { + name: "get non-existing message status", + messageID: "msg-nonexistent", + setupStatus: http.StatusNotFound, + setupBody: `{"error": "Message not found"}`, + expectJSON: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/messages/"+tt.messageID, r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.setupStatus) + w.Write([]byte(tt.setupBody)) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "status", + tt.messageID, + } + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if tt.setupStatus == http.StatusNotFound { + assert.Error(t, err) + assert.Contains(t, stderr.String(), "Message not found") + } else { + assert.NoError(t, err, "stderr: %s", stderr.String()) + assert.Contains(t, stdout.String(), "msg-12345") + } + }) + } +} + +func TestMessageStatusInvalid(t *testing.T) { + tests := []struct { + name string + messageID string + expectedErr string + setupStatus int + }{ + { + name: "empty message ID", + messageID: "", + expectedErr: "Message ID is empty", + setupStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.setupStatus) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "status", + } + + if tt.messageID != "" { + args = append(args, tt.messageID) + } + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, stderr.String(), tt.expectedErr) + }) + } +} + +func TestMessageCommandHelp(t *testing.T) { + tests := []struct { + name string + args []string + expectHelp bool + expectUsage bool + }{ + { + name: "send command help", + args: []string{"send", "--help"}, + expectHelp: true, + expectUsage: true, + }, + { + name: "status command help", + args: []string{"status", "--help"}, + expectHelp: true, + expectUsage: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + cmd := exec.Command("./smsgate", tt.args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.NoError(t, err, "stderr: %s", stderr.String()) + + output := stdout.String() + if tt.expectHelp { + assert.Contains(t, output, "help") + } + if tt.expectUsage { + assert.Contains(t, output, "USAGE:") + } + }) + } +} diff --git a/tests/e2e/testutils/testutils.go b/tests/e2e/testutils/testutils.go new file mode 100644 index 0000000..8a5e01c --- /dev/null +++ b/tests/e2e/testutils/testutils.go @@ -0,0 +1,10 @@ +package testutils + +import ( + "net/http" + "net/http/httptest" +) + +func CreateMockServer(handler http.HandlerFunc) *httptest.Server { + return httptest.NewServer(handler) +} diff --git a/tests/e2e/webhooks_test.go b/tests/e2e/webhooks_test.go new file mode 100644 index 0000000..e0cb6ea --- /dev/null +++ b/tests/e2e/webhooks_test.go @@ -0,0 +1,391 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "testing" + + "e2e/testutils" + + "github.com/stretchr/testify/assert" +) + +func TestWebhookRegisterValid(t *testing.T) { + tests := []struct { + name string + url string + event string + id string + expectJSON bool + }{ + { + name: "register webhook with `sms:received` event", + url: "https://example.com/webhook", + event: "sms:received", + id: "", + expectJSON: true, + }, + { + name: "register webhook with `sms:delivered` event", + url: "https://example.com/delivery", + event: "sms:delivered", + id: "test-id", + expectJSON: true, + }, + { + name: "register webhook with `sms:sent` event", + url: "https://example.com/status", + event: "sms:sent", + id: "", + expectJSON: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/webhooks", r.URL.Path) + + var webhook struct { + ID string `json:"id"` + URL string `json:"url"` + Event string `json:"event"` + } + err := json.NewDecoder(r.Body).Decode(&webhook) + assert.NoError(t, err) + assert.Equal(t, tt.url, webhook.URL) + assert.Equal(t, tt.event, webhook.Event) + if tt.id != "" { + assert.Equal(t, tt.id, webhook.ID) + } + + response := map[string]interface{}{ + "id": "wh-test-123", + "url": webhook.URL, + "event": webhook.Event, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "--format", "json", + "webhooks", "register", + "--event", tt.event, + } + if tt.id != "" { + args = append(args, "--id", tt.id) + } + + args = append(args, tt.url) + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.NoError(t, err, "stderr: %s", stderr.String()) + + if tt.expectJSON { + var response map[string]any + err := json.Unmarshal(stdout.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "wh-test-123", response["id"]) + } else { + assert.Contains(t, stdout.String(), "Webhook registered successfully") + } + }) + } +} + +func TestWebhookRegisterInvalid(t *testing.T) { + tests := []struct { + name string + url string + event string + id string + expected string + }{ + { + name: "missing url", + url: "", + event: "sms:received", + id: "", + expected: "URL is empty", + }, + { + name: "invalid url", + url: "not-a-url", + event: "sms:received", + id: "", + expected: "invalid URL", + }, + { + name: "missing event", + url: "https://example.com/webhook", + event: "", + id: "", + expected: "Required flag \"event\" not set", + }, + { + name: "invalid event", + url: "https://example.com/webhook", + event: "invalid-event", + id: "", + expected: "Invalid event", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "webhooks", "register", + } + if tt.event != "" { + args = append(args, "--event", tt.event) + } + if tt.id != "" { + args = append(args, "--id", tt.id) + } + if tt.url != "" { + args = append(args, tt.url) + } + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, stderr.String(), tt.expected) + }) + } +} + +func TestWebhookList(t *testing.T) { + tests := []struct { + name string + setupResponse func() []byte + expectedCount int + expectJSON bool + }{ + { + name: "list empty webhooks", + setupResponse: func() []byte { + return []byte("[]") + }, + expectedCount: 0, + expectJSON: true, + }, + { + name: "list multiple webhooks", + setupResponse: func() []byte { + return []byte(`[ + { + "id": "wh-001", + "url": "https://example.com/message", + "event": "message" + }, + { + "id": "wh-002", + "url": "https://example.com/delivery", + "event": "delivery" + } + ]`) + }, + expectedCount: 2, + expectJSON: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/webhooks", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(tt.setupResponse()) + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + cmd := exec.Command( + "./smsgate", + "--format", "json", + "webhooks", "list", + ) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.NoError(t, err, "stderr: %s", stderr.String()) + + if tt.expectJSON { + var webhooks []map[string]interface{} + err := json.Unmarshal(stdout.Bytes(), &webhooks) + assert.NoError(t, err) + assert.Len(t, webhooks, tt.expectedCount) + } + }) + } +} + +func TestWebhookDelete(t *testing.T) { + tests := []struct { + name string + webhookID string + setupStatus int + expectError bool + expectedMsg string + }{ + { + name: "delete existing webhook", + webhookID: "wh-test-123", + setupStatus: http.StatusOK, + expectError: false, + expectedMsg: "Success", + }, + { + name: "delete non-existing webhook", + webhookID: "wh-nonexistent", + setupStatus: http.StatusNotFound, + expectError: true, + expectedMsg: "not found", + }, + { + name: "delete webhook with empty id", + webhookID: "", + setupStatus: http.StatusBadRequest, + expectError: true, + expectedMsg: "ID is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := testutils.CreateMockServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + if tt.webhookID != "" { + assert.Equal(t, "/webhooks/"+tt.webhookID, r.URL.Path) + } + + switch tt.setupStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "Webhook not found"}`)) + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "Invalid webhook ID"}`)) + default: + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) + } + }) + defer mockServer.Close() + + var stdout, stderr bytes.Buffer + args := []string{"webhooks", "delete"} + if tt.webhookID != "" { + args = append(args, tt.webhookID) + } + + cmd := exec.Command("./smsgate", args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, fmt.Sprintf("ASG_ENDPOINT=%s", mockServer.URL)) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, stderr.String(), tt.expectedMsg) + } else { + assert.NoError(t, err, "stderr: %s", stderr.String()) + assert.Contains(t, stdout.String(), tt.expectedMsg) + } + }) + } +} + +func TestWebhookCommandHelp(t *testing.T) { + tests := []struct { + name string + args []string + expectHelp bool + expectUsage bool + }{ + { + name: "webhooks command help", + args: []string{"webhooks", "--help"}, + expectHelp: true, + expectUsage: true, + }, + { + name: "register command help", + args: []string{"webhooks", "register", "--help"}, + expectHelp: true, + expectUsage: true, + }, + { + name: "list command help", + args: []string{"webhooks", "list", "--help"}, + expectHelp: true, + expectUsage: true, + }, + { + name: "delete command help", + args: []string{"webhooks", "delete", "--help"}, + expectHelp: true, + expectUsage: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + cmd := exec.Command("./smsgate", tt.args...) + cmd.Env = append([]string{}, os.Environ()...) + cmd.Env = append(cmd.Env, "ASG_USERNAME=testuser", "ASG_PASSWORD=testpass") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + assert.NoError(t, err, "stderr: %s", stderr.String()) + + output := stdout.String() + if tt.expectHelp { + assert.Contains(t, output, "help") + } + if tt.expectUsage { + assert.Contains(t, output, "USAGE:") + } + }) + } +}