diff --git a/internal/cli/confirmation.go b/internal/cli/confirmation.go index cbf74a0..3be45d2 100644 --- a/internal/cli/confirmation.go +++ b/internal/cli/confirmation.go @@ -7,16 +7,16 @@ import ( "strings" ) -func AskForConfirmation(s string) bool { +func AskForConfirmation(s string) (bool, error) { reader := bufio.NewReader(os.Stdin) fmt.Printf("%s [y/N]: ", s) response, err := reader.ReadString('\n') if err != nil { - panic(err) + return false, fmt.Errorf("failed to read user input: %w", err) } response = strings.ToLower(strings.TrimSpace(response)) if response == "y" || response == "yes" { - return true + return true, nil } - return false + return false, nil } diff --git a/internal/cli/confirmation_test.go b/internal/cli/confirmation_test.go new file mode 100644 index 0000000..ae4acd5 --- /dev/null +++ b/internal/cli/confirmation_test.go @@ -0,0 +1,230 @@ +package cli_test + +import ( + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/cli" +) + +func Test_AskForConfirmation(t *testing.T) { + t.Run("Returns true for 'y' input", func(t *testing.T) { + // Create a pipe to simulate stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + // Save original stdin + oldStdin := os.Stdin + os.Stdin = r + + // Write the input + go func() { + w.WriteString("y\n") + w.Close() + }() + + // Restore stdin after test + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !result { + t.Error("Expected true for 'y' input, got false") + } + }) + + t.Run("Returns true for 'yes' input", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("yes\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !result { + t.Error("Expected true for 'yes' input, got false") + } + }) + + t.Run("Returns true for 'Y' input (case insensitive)", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("Y\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !result { + t.Error("Expected true for 'Y' input, got false") + } + }) + + t.Run("Returns true for 'YES' input (case insensitive)", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("YES\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !result { + t.Error("Expected true for 'YES' input, got false") + } + }) + + t.Run("Returns false for 'n' input", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("n\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result { + t.Error("Expected false for 'n' input, got true") + } + }) + + t.Run("Returns false for 'no' input", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("no\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result { + t.Error("Expected false for 'no' input, got true") + } + }) + + t.Run("Returns false for empty input", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result { + t.Error("Expected false for empty input, got true") + } + }) + + t.Run("Returns false for random input", func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString("maybe\n") + w.Close() + }() + + t.Cleanup(func() { + os.Stdin = oldStdin + }) + + result, err := cli.AskForConfirmation("Test prompt") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result { + t.Error("Expected false for random input, got true") + } + }) +} diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go new file mode 100644 index 0000000..74fb057 --- /dev/null +++ b/internal/cmd/app_test.go @@ -0,0 +1,74 @@ +package cmd_test + +import ( + "testing" + + "github.com/openstatusHQ/cli/internal/cmd" +) + +func Test_NewApp(t *testing.T) { + t.Parallel() + + t.Run("Returns valid app command", func(t *testing.T) { + app := cmd.NewApp() + + if app == nil { + t.Fatal("Expected non-nil app") + } + + if app.Name != "openstatus" { + t.Errorf("Expected app name 'openstatus', got %s", app.Name) + } + + if app.Version != "v1.0.0" { + t.Errorf("Expected version 'v1.0.0', got %s", app.Version) + } + + if !app.Suggest { + t.Error("Expected Suggest to be true") + } + }) + + t.Run("Has expected commands", func(t *testing.T) { + app := cmd.NewApp() + + if len(app.Commands) != 3 { + t.Errorf("Expected 3 commands, got %d", len(app.Commands)) + } + + expectedCommands := map[string]bool{ + "monitors": false, + "run": false, + "whoami": false, + } + + for _, subcmd := range app.Commands { + if _, exists := expectedCommands[subcmd.Name]; exists { + expectedCommands[subcmd.Name] = true + } + } + + for name, found := range expectedCommands { + if !found { + t.Errorf("Expected command '%s' not found", name) + } + } + }) + + t.Run("Has correct usage text", func(t *testing.T) { + app := cmd.NewApp() + + expectedUsage := "This is OpenStatus Command Line Interface, the OpenStatus.dev CLI" + if app.Usage != expectedUsage { + t.Errorf("Expected usage '%s', got '%s'", expectedUsage, app.Usage) + } + }) + + t.Run("Has description", func(t *testing.T) { + app := cmd.NewApp() + + if app.Description == "" { + t.Error("Expected non-empty description") + } + }) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0fa8df6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openstatusHQ/cli/internal/config" +) + +var configFile = ` +tests: + ids: + - 1 + - 2 + - 3 +` + +func Test_ReadConfig(t *testing.T) { + t.Run("Read valid config file", func(t *testing.T) { + f, err := os.CreateTemp(".", "config*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.Write([]byte(configFile)); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + out, err := config.ReadConfig(f.Name()) + if err != nil { + t.Fatal(err) + } + + expect := &config.Config{ + Tests: config.TestsConfig{ + Ids: []int{1, 2, 3}, + }, + } + + if !cmp.Equal(expect, out) { + t.Errorf("Expected %v, got %v", expect, out) + } + }) + + t.Run("File does not exist", func(t *testing.T) { + _, err := config.ReadConfig("nonexistent.yaml") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + }) + + t.Run("Invalid YAML content", func(t *testing.T) { + f, err := os.CreateTemp(".", "invalid*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.Write([]byte("invalid: yaml: content: [")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + _, err = config.ReadConfig(f.Name()) + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } + }) +} diff --git a/internal/config/lock.go b/internal/config/lock.go index 750fc34..1a304f4 100644 --- a/internal/config/lock.go +++ b/internal/config/lock.go @@ -37,14 +37,7 @@ func ReadLockFile(filename string) (MonitorsLock, error) { } for _, value := range out { - for _, assertion := range value.Monitor.Assertions { - if assertion.Kind == Header || assertion.Kind == TextBody { - assertion.Target = assertion.Target.(string) - } - if assertion.Kind == StatusCode { - assertion.Target = assertion.Target.(int) - } - } + ConvertAssertionTargets(value.Monitor.Assertions) } return out, nil diff --git a/internal/config/monitor.go b/internal/config/monitor.go index 835c505..a1629ad 100644 --- a/internal/config/monitor.go +++ b/internal/config/monitor.go @@ -158,3 +158,27 @@ type Target struct { Int *int64 String *string } + +// ConvertAssertionTargets safely converts assertion targets to the appropriate types +// based on the assertion kind. This prevents panics from unsafe type assertions. +func ConvertAssertionTargets(assertions []Assertion) { + for i := range assertions { + assertion := &assertions[i] + switch assertion.Kind { + case Header, TextBody: + if s, ok := assertion.Target.(string); ok { + assertion.Target = s + } + case StatusCode: + // YAML may parse integers as int, int64, or float64 + switch v := assertion.Target.(type) { + case int: + assertion.Target = v + case int64: + assertion.Target = int(v) + case float64: + assertion.Target = int(v) + } + } + } +} diff --git a/internal/config/monitor_test.go b/internal/config/monitor_test.go new file mode 100644 index 0000000..53a5c30 --- /dev/null +++ b/internal/config/monitor_test.go @@ -0,0 +1,145 @@ +package config_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openstatusHQ/cli/internal/config" +) + +func Test_ConvertAssertionTargets(t *testing.T) { + t.Run("Convert StatusCode with int target", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: 200, + }, + } + + config.ConvertAssertionTargets(assertions) + + if assertions[0].Target != 200 { + t.Errorf("Expected target to be 200, got %v", assertions[0].Target) + } + }) + + t.Run("Convert StatusCode with int64 target", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: int64(200), + }, + } + + config.ConvertAssertionTargets(assertions) + + if assertions[0].Target != 200 { + t.Errorf("Expected target to be 200, got %v", assertions[0].Target) + } + }) + + t.Run("Convert StatusCode with float64 target", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: float64(200), + }, + } + + config.ConvertAssertionTargets(assertions) + + if assertions[0].Target != 200 { + t.Errorf("Expected target to be 200, got %v", assertions[0].Target) + } + }) + + t.Run("Convert Header with string target", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.Header, + Compare: config.Eq, + Target: "application/json", + Key: "Content-Type", + }, + } + + config.ConvertAssertionTargets(assertions) + + if assertions[0].Target != "application/json" { + t.Errorf("Expected target to be 'application/json', got %v", assertions[0].Target) + } + }) + + t.Run("Convert TextBody with string target", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.TextBody, + Compare: config.Contains, + Target: "success", + }, + } + + config.ConvertAssertionTargets(assertions) + + if assertions[0].Target != "success" { + t.Errorf("Expected target to be 'success', got %v", assertions[0].Target) + } + }) + + t.Run("Convert multiple assertions", func(t *testing.T) { + assertions := []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: float64(200), + }, + { + Kind: config.Header, + Compare: config.Eq, + Target: "application/json", + Key: "Content-Type", + }, + { + Kind: config.TextBody, + Compare: config.Contains, + Target: "OK", + }, + } + + config.ConvertAssertionTargets(assertions) + + expected := []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: 200, + }, + { + Kind: config.Header, + Compare: config.Eq, + Target: "application/json", + Key: "Content-Type", + }, + { + Kind: config.TextBody, + Compare: config.Contains, + Target: "OK", + }, + } + + if !cmp.Equal(expected, assertions) { + t.Errorf("Expected %v, got %v", expected, assertions) + } + }) + + t.Run("Empty assertions slice", func(t *testing.T) { + assertions := []config.Assertion{} + config.ConvertAssertionTargets(assertions) + if len(assertions) != 0 { + t.Errorf("Expected empty slice, got %v", assertions) + } + }) +} diff --git a/internal/config/openstatus.go b/internal/config/openstatus.go index 7974188..9895bf0 100644 --- a/internal/config/openstatus.go +++ b/internal/config/openstatus.go @@ -25,14 +25,7 @@ func ReadOpenStatus(path string) (Monitors, error) { } for _, value := range out { - for _, assertion := range value.Assertions { - if assertion.Kind == Header || assertion.Kind == TextBody { - assertion.Target = assertion.Target.(string) - } - if assertion.Kind == StatusCode { - assertion.Target = assertion.Target.(int) - } - } + ConvertAssertionTargets(value.Assertions) } return out, nil @@ -41,14 +34,7 @@ func ReadOpenStatus(path string) (Monitors, error) { func ParseConfigMonitorsToMonitor(monitors Monitors) []Monitor { var monitor []Monitor for _, value := range monitors { - for _, assertion := range value.Assertions { - if assertion.Kind == Header || assertion.Kind == TextBody { - assertion.Target = assertion.Target.(string) - } - if assertion.Kind == StatusCode { - assertion.Target = assertion.Target.(int) - } - } + ConvertAssertionTargets(value.Assertions) monitor = append(monitor, value) } diff --git a/internal/config/openstatus_test.go b/internal/config/openstatus_test.go new file mode 100644 index 0000000..736f108 --- /dev/null +++ b/internal/config/openstatus_test.go @@ -0,0 +1,175 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/config" +) + +var openstatusConfig = ` +"test-monitor": + active: true + assertions: + - compare: eq + kind: statusCode + target: 200 + description: Test monitor description + frequency: 10m + kind: http + name: Test Monitor + regions: + - iad + - ams + request: + headers: + User-Agent: OpenStatus + method: GET + url: https://example.com + retry: 3 +` + +func Test_ReadOpenStatus(t *testing.T) { + t.Run("Read valid openstatus config", func(t *testing.T) { + f, err := os.CreateTemp(".", "openstatus*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.Write([]byte(openstatusConfig)); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + out, err := config.ReadOpenStatus(f.Name()) + if err != nil { + t.Fatal(err) + } + + // Check that the monitor was read correctly + // Note: We check for the specific monitor because the global koanf instance + // may have accumulated state from previous tests + monitor, exists := out["test-monitor"] + if !exists { + t.Fatal("Expected 'test-monitor' to exist in output") + } + + if monitor.Name != "Test Monitor" { + t.Errorf("Expected name 'Test Monitor', got %s", monitor.Name) + } + if monitor.Description != "Test monitor description" { + t.Errorf("Expected description 'Test monitor description', got %s", monitor.Description) + } + if monitor.Frequency != config.The10M { + t.Errorf("Expected frequency '10m', got %s", monitor.Frequency) + } + if monitor.Kind != config.HTTP { + t.Errorf("Expected kind 'http', got %s", monitor.Kind) + } + if !monitor.Active { + t.Error("Expected monitor to be active") + } + if monitor.Retry != 3 { + t.Errorf("Expected retry 3, got %d", monitor.Retry) + } + if len(monitor.Regions) != 2 { + t.Errorf("Expected 2 regions, got %d", len(monitor.Regions)) + } + if monitor.Request.URL != "https://example.com" { + t.Errorf("Expected URL 'https://example.com', got %s", monitor.Request.URL) + } + if monitor.Request.Method != config.Get { + t.Errorf("Expected method 'GET', got %s", monitor.Request.Method) + } + if len(monitor.Assertions) != 1 { + t.Errorf("Expected 1 assertion, got %d", len(monitor.Assertions)) + } + }) + + t.Run("File does not exist", func(t *testing.T) { + _, err := config.ReadOpenStatus("nonexistent.yaml") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + }) +} + +func Test_ParseConfigMonitorsToMonitor(t *testing.T) { + t.Run("Parse monitors map to slice", func(t *testing.T) { + monitors := config.Monitors{ + "monitor-1": { + Name: "Monitor 1", + Active: true, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example1.com", + Method: config.Get, + }, + }, + "monitor-2": { + Name: "Monitor 2", + Active: false, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Ams}, + Request: config.Request{ + URL: "https://example2.com", + Method: config.Post, + }, + }, + } + + result := config.ParseConfigMonitorsToMonitor(monitors) + + if len(result) != 2 { + t.Errorf("Expected 2 monitors, got %d", len(result)) + } + }) + + t.Run("Empty monitors map", func(t *testing.T) { + monitors := config.Monitors{} + result := config.ParseConfigMonitorsToMonitor(monitors) + + if len(result) != 0 { + t.Errorf("Expected 0 monitors, got %d", len(result)) + } + }) + + t.Run("Assertions are converted", func(t *testing.T) { + monitors := config.Monitors{ + "monitor-1": { + Name: "Monitor 1", + Active: true, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + Assertions: []config.Assertion{ + { + Kind: config.StatusCode, + Compare: config.Eq, + Target: float64(200), + }, + }, + }, + } + + result := config.ParseConfigMonitorsToMonitor(monitors) + + if len(result) != 1 { + t.Fatalf("Expected 1 monitor, got %d", len(result)) + } + + if result[0].Assertions[0].Target != 200 { + t.Errorf("Expected target to be converted to int 200, got %v", result[0].Assertions[0].Target) + } + }) +} diff --git a/internal/monitors/monitor_apply.go b/internal/monitors/monitor_apply.go index 322e0aa..7e14da9 100644 --- a/internal/monitors/monitor_apply.go +++ b/internal/monitors/monitor_apply.go @@ -101,7 +101,11 @@ func CompareLockWithConfig(apiKey string, applyChange bool, lock config.Monitors fmt.Println("Monitor Delete:", deleted) } - if !confirmation.AskForConfirmation(("Do you want to continue?")) { + confirmed, err := confirmation.AskForConfirmation("Do you want to continue?") + if err != nil { + return nil, fmt.Errorf("failed to read user input: %w", err) + } + if !confirmed { return nil, nil } return lock, nil diff --git a/internal/monitors/monitor_apply_test.go b/internal/monitors/monitor_apply_test.go new file mode 100644 index 0000000..d588481 --- /dev/null +++ b/internal/monitors/monitor_apply_test.go @@ -0,0 +1,316 @@ +package monitors_test + +import ( + "bytes" + "log" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/config" + "github.com/openstatusHQ/cli/internal/monitors" +) + +// setupStdinWithInput creates a pipe to simulate stdin with the given input +func setupStdinWithInput(t *testing.T, input string) func() { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + + go func() { + w.WriteString(input) + w.Close() + }() + + return func() { + os.Stdin = oldStdin + } +} + +func Test_CompareLockWithConfig(t *testing.T) { + t.Run("No changes detected", func(t *testing.T) { + monitor := config.Monitor{ + Name: "Test Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + lock := config.MonitorsLock{ + "test-monitor": { + ID: 123, + Monitor: monitor, + }, + } + + configData := config.Monitors{ + "test-monitor": monitor, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + + result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result != nil { + t.Errorf("Expected nil result when no changes, got %v", result) + } + }) + + t.Run("Detects new monitor to create and user declines", func(t *testing.T) { + lock := config.MonitorsLock{} + + configData := config.Monitors{ + "new-monitor": { + Name: "New Monitor", + Active: true, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Ams}, + Request: config.Request{ + URL: "https://new.example.com", + Method: config.Get, + }, + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + + // Setup stdin to decline the confirmation + cleanup := setupStdinWithInput(t, "n\n") + defer cleanup() + + // When applyChange is false, it should detect the creation needed + // and ask for confirmation (which we decline with "n") + result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Result should be nil because user declined + if result != nil { + t.Errorf("Expected nil result when user declines, got %v", result) + } + }) + + t.Run("Detects monitor update needed and user declines", func(t *testing.T) { + originalMonitor := config.Monitor{ + Name: "Test Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + updatedMonitor := config.Monitor{ + Name: "Test Monitor Updated", + Active: true, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad, config.Ams}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + lock := config.MonitorsLock{ + "test-monitor": { + ID: 123, + Monitor: originalMonitor, + }, + } + + configData := config.Monitors{ + "test-monitor": updatedMonitor, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + + // Setup stdin to decline the confirmation + cleanup := setupStdinWithInput(t, "n\n") + defer cleanup() + + result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Result should be nil because user declined + if result != nil { + t.Errorf("Expected nil result when user declines, got %v", result) + } + }) + + t.Run("Detects monitor deletion needed and user declines", func(t *testing.T) { + monitor := config.Monitor{ + Name: "Test Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + lock := config.MonitorsLock{ + "test-monitor": { + ID: 123, + Monitor: monitor, + }, + } + + configData := config.Monitors{} + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + + // Setup stdin to decline the confirmation + cleanup := setupStdinWithInput(t, "n\n") + defer cleanup() + + result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Result should be nil because user declined + if result != nil { + t.Errorf("Expected nil result when user declines, got %v", result) + } + }) + + t.Run("Mixed changes detected and user declines", func(t *testing.T) { + existingMonitor := config.Monitor{ + Name: "Existing Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://existing.example.com", + Method: config.Get, + }, + } + + toUpdateMonitor := config.Monitor{ + Name: "To Update Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://update.example.com", + Method: config.Get, + }, + } + + toDeleteMonitor := config.Monitor{ + Name: "To Delete Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://delete.example.com", + Method: config.Get, + }, + } + + lock := config.MonitorsLock{ + "existing-monitor": { + ID: 1, + Monitor: existingMonitor, + }, + "to-update-monitor": { + ID: 2, + Monitor: toUpdateMonitor, + }, + "to-delete-monitor": { + ID: 3, + Monitor: toDeleteMonitor, + }, + } + + updatedMonitor := config.Monitor{ + Name: "To Update Monitor - Updated", + Active: false, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad, config.Ams}, + Request: config.Request{ + URL: "https://update.example.com", + Method: config.Post, + }, + } + + newMonitor := config.Monitor{ + Name: "New Monitor", + Active: true, + Frequency: config.The1M, + Kind: config.HTTP, + Regions: []config.Region{config.Syd}, + Request: config.Request{ + URL: "https://new.example.com", + Method: config.Get, + }, + } + + configData := config.Monitors{ + "existing-monitor": existingMonitor, + "to-update-monitor": updatedMonitor, + "new-monitor": newMonitor, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + + // Setup stdin to decline the confirmation + cleanup := setupStdinWithInput(t, "n\n") + defer cleanup() + + result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Result should be nil because user declined + if result != nil { + t.Errorf("Expected nil result when user declines, got %v", result) + } + }) +} diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index 0ffa069..3a5067b 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -17,11 +17,14 @@ import ( func CreateMonitor(httpClient *http.Client, apiKey string, monitor config.Monitor) (Monitor, error) { - url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s", monitor.Kind) + url := fmt.Sprintf("%s/monitor/%s", APIBaseURL, monitor.Kind) payloadBuf := new(bytes.Buffer) json.NewEncoder(payloadBuf).Encode(monitor) - req, _ := http.NewRequest(http.MethodPost, url, payloadBuf) + req, err := http.NewRequest(http.MethodPost, url, payloadBuf) + if err != nil { + return Monitor{}, fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) req.Header.Add("Content-Type", "application/json") @@ -35,7 +38,10 @@ func CreateMonitor(httpClient *http.Client, apiKey string, monitor config.Monito return Monitor{}, fmt.Errorf("Failed to create monitor") } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return Monitor{}, fmt.Errorf("failed to read response body: %w", err) + } var monitors Monitor err = json.Unmarshal(body, &monitors) @@ -74,7 +80,11 @@ func GetMonitorCreateCmd() *cli.Command { } if !accept { - if !confirmation.AskForConfirmation(fmt.Sprintf("You are about to create %d monitors do you want to continue", len(monitors))) { + confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to create %d monitors do you want to continue", len(monitors))) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) + } + if !confirmed { return nil } } diff --git a/internal/monitors/monitor_create_test.go b/internal/monitors/monitor_create_test.go new file mode 100644 index 0000000..f854134 --- /dev/null +++ b/internal/monitors/monitor_create_test.go @@ -0,0 +1,162 @@ +package monitors_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openstatusHQ/cli/internal/config" + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_CreateMonitor(t *testing.T) { + t.Parallel() + + t.Run("Create monitor successfully", func(t *testing.T) { + body := `{ + "id": 123, + "name": "Test Monitor", + "url": "https://example.com", + "periodicity": "10m", + "method": "GET", + "regions": ["iad", "ams"], + "active": true, + "public": false, + "timeout": 45000, + "body": "", + "retry": 3, + "jobType": "http" + }` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Errorf("Expected POST method, got %s", req.Method) + } + if req.Header.Get("x-openstatus-key") != "test-api-key" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type header, got %s", req.Header.Get("Content-Type")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Test Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad, config.Ams}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + Retry: 3, + } + + result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 123 { + t.Errorf("Expected ID 123, got %d", result.ID) + } + if result.Name != "Test Monitor" { + t.Errorf("Expected name 'Test Monitor', got %s", result.Name) + } + }) + + t.Run("Create monitor fails with non-200 status", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "bad request"}`))), + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Test Monitor", + Kind: config.HTTP, + } + + _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err == nil { + t.Error("Expected error for non-200 status, got nil") + } + }) + + t.Run("Create monitor returns correct Monitor struct", func(t *testing.T) { + body := `{ + "id": 456, + "name": "Full Monitor", + "url": "https://test.example.com", + "periodicity": "5m", + "description": "Test description", + "method": "POST", + "regions": ["iad", "ams", "syd"], + "active": true, + "public": true, + "timeout": 30000, + "degraded_after": 5000, + "body": "{\"key\": \"value\"}", + "headers": [{"key": "Content-Type", "value": "application/json"}], + "assertions": [{"type": "statusCode", "compare": "eq", "target": 200}], + "retry": 2, + "jobType": "http" + }` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Full Monitor", + Kind: config.HTTP, + } + + result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expected := monitors.Monitor{ + ID: 456, + Name: "Full Monitor", + URL: "https://test.example.com", + Periodicity: "5m", + Description: "Test description", + Method: "POST", + Regions: []string{"iad", "ams", "syd"}, + Active: true, + Public: true, + Timeout: 30000, + DegradedAfter: 5000, + Body: "{\"key\": \"value\"}", + Headers: []monitors.Header{{Key: "Content-Type", Value: "application/json"}}, + Assertions: []monitors.Assertion{{Type: "statusCode", Compare: "eq", Target: float64(200)}}, + Retry: 2, + JobType: "http", + } + + if !cmp.Equal(expected, result) { + t.Errorf("Expected %+v, got %+v", expected, result) + } + }) +} diff --git a/internal/monitors/monitor_delete.go b/internal/monitors/monitor_delete.go index 004c177..80f94dd 100644 --- a/internal/monitors/monitor_delete.go +++ b/internal/monitors/monitor_delete.go @@ -17,7 +17,7 @@ func DeleteMonitor(httpClient *http.Client, apiKey string, monitorId string) err return fmt.Errorf("Monitor ID is required") } - url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s", monitorId) + url := fmt.Sprintf("%s/monitor/%s", APIBaseURL, monitorId) req, err := http.NewRequest(http.MethodDelete, url, nil) if err != nil { @@ -34,7 +34,10 @@ func DeleteMonitor(httpClient *http.Client, apiKey string, monitorId string) err } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var r MonitorTriggerResponse err = json.Unmarshal(body, &r) @@ -73,7 +76,11 @@ func GetMonitorDeleteCmd() *cli.Command { monitorId := cmd.Args().Get(0) if !cmd.Bool("auto-accept") { - if !confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete monitor: %s, do you want to continue", monitorId)) { + confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete monitor: %s, do you want to continue", monitorId)) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) + } + if !confirmed { return nil } } diff --git a/internal/monitors/monitor_delete_test.go b/internal/monitors/monitor_delete_test.go new file mode 100644 index 0000000..7bb45cc --- /dev/null +++ b/internal/monitors/monitor_delete_test.go @@ -0,0 +1,96 @@ +package monitors_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_DeleteMonitor(t *testing.T) { + t.Parallel() + + t.Run("Monitor ID is required", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + }, nil + }, + } + + err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "") + if err == nil { + t.Error("Expected error for empty monitor ID, got nil") + } + if err.Error() != "Monitor ID is required" { + t.Errorf("Expected 'Monitor ID is required' error, got %v", err) + } + }) + + t.Run("Delete monitor successfully", func(t *testing.T) { + body := `{"resultId": 123}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodDelete { + t.Errorf("Expected DELETE method, got %s", req.Method) + } + if req.Header.Get("x-openstatus-key") != "test-api-key" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + expectedURL := "https://api.openstatus.dev/v1/monitor/123" + if req.URL.String() != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "123") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Delete monitor fails with non-200 status", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))), + }, nil + }, + } + + err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "999") + if err == nil { + t.Error("Expected error for non-200 status, got nil") + } + }) + + t.Run("Delete monitor with valid response body", func(t *testing.T) { + body := `{"resultId": 456}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "456") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) +} diff --git a/internal/monitors/monitor_import.go b/internal/monitors/monitor_import.go index 249aada..cc80d7c 100644 --- a/internal/monitors/monitor_import.go +++ b/internal/monitors/monitor_import.go @@ -16,9 +16,12 @@ import ( ) func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { - url := "https://api.openstatus.dev/v1/monitor" + url := APIBaseURL + "/monitor" - req, _ := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) res, err := httpClient.Do(req) if err != nil { @@ -26,11 +29,14 @@ func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { } if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to Get all monitors monitors") + return fmt.Errorf("failed to get all monitors") } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var monitors []Monitor err = json.Unmarshal(body, &monitors) if err != nil { diff --git a/internal/monitors/monitor_import_test.go b/internal/monitors/monitor_import_test.go new file mode 100644 index 0000000..561a232 --- /dev/null +++ b/internal/monitors/monitor_import_test.go @@ -0,0 +1,261 @@ +package monitors_test + +import ( + "bytes" + "io" + "net/http" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_ExportMonitor(t *testing.T) { + t.Parallel() + + t.Run("Export HTTP monitors successfully", func(t *testing.T) { + body := `[ + { + "id": 123, + "name": "HTTP Monitor", + "url": "https://example.com", + "periodicity": "10m", + "description": "Test monitor", + "method": "GET", + "regions": ["iad", "ams"], + "active": true, + "public": false, + "timeout": 45000, + "body": "", + "headers": [{"key": "User-Agent", "value": "OpenStatus"}], + "assertions": [{"type": "statusCode", "compare": "eq", "target": 200}], + "retry": 3, + "jobType": "http" + } + ]` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", req.Method) + } + if req.Header.Get("x-openstatus-key") != "test-api-key" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + outputFile, err := os.CreateTemp(".", "export*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(outputFile.Name()) + defer os.Remove("openstatus.lock") + outputFile.Close() + + err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + content, err := os.ReadFile(outputFile.Name()) + if err != nil { + t.Fatal(err) + } + + if len(content) == 0 { + t.Error("Expected non-empty output file") + } + }) + + t.Run("Export TCP monitors successfully", func(t *testing.T) { + body := `[ + { + "id": 456, + "name": "TCP Monitor", + "url": "example.com:443", + "periodicity": "5m", + "description": "TCP test", + "method": "", + "regions": ["iad"], + "active": true, + "public": false, + "timeout": 10000, + "body": "", + "headers": [], + "assertions": [], + "retry": 0, + "jobType": "tcp" + } + ]` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + outputFile, err := os.CreateTemp(".", "export_tcp*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(outputFile.Name()) + defer os.Remove("openstatus.lock") + outputFile.Close() + + err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Export fails with non-200 status", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "unauthorized"}`))), + }, nil + }, + } + + err := monitors.ExportMonitor(interceptor.GetHTTPClient(), "invalid-key", "output.yaml") + if err == nil { + t.Error("Expected error for non-200 status, got nil") + } + }) + + t.Run("Export fails with unknown job type", func(t *testing.T) { + body := `[ + { + "id": 789, + "name": "Unknown Monitor", + "url": "https://example.com", + "periodicity": "10m", + "method": "GET", + "regions": ["iad"], + "active": true, + "jobType": "unknown" + } + ]` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + outputFile, err := os.CreateTemp(".", "export_unknown*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(outputFile.Name()) + outputFile.Close() + + err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + if err == nil { + t.Error("Expected error for unknown job type, got nil") + } + }) + + t.Run("Export handles empty headers", func(t *testing.T) { + body := `[ + { + "id": 111, + "name": "No Headers Monitor", + "url": "https://example.com", + "periodicity": "10m", + "method": "GET", + "regions": ["iad"], + "active": true, + "public": false, + "timeout": 45000, + "body": "", + "headers": [{"key": "", "value": ""}], + "assertions": [], + "retry": 0, + "jobType": "http" + } + ]` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + outputFile, err := os.CreateTemp(".", "export_noheaders*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(outputFile.Name()) + defer os.Remove("openstatus.lock") + outputFile.Close() + + err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Export handles status assertion type conversion", func(t *testing.T) { + body := `[ + { + "id": 222, + "name": "Assertion Monitor", + "url": "https://example.com", + "periodicity": "10m", + "method": "GET", + "regions": ["iad"], + "active": true, + "public": false, + "timeout": 45000, + "body": "", + "headers": [], + "assertions": [{"type": "status", "compare": "eq", "target": 200}], + "retry": 0, + "jobType": "http" + } + ]` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + outputFile, err := os.CreateTemp(".", "export_assertion*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(outputFile.Name()) + defer os.Remove("openstatus.lock") + outputFile.Close() + + err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) +} diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index bf0c4b6..b17deb2 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -23,9 +23,12 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("Monitor ID is required") } - url := "https://api.openstatus.dev/v1/monitor/" + monitorId + url := APIBaseURL + "/monitor/" + monitorId - req, _ := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) @@ -37,7 +40,10 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("You don't have permission to access this monitor") } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var monitor Monitor err = json.Unmarshal(body, &monitor) diff --git a/internal/monitors/monitor_trigger.go b/internal/monitors/monitor_trigger.go index 4dc9a3c..4467f08 100644 --- a/internal/monitors/monitor_trigger.go +++ b/internal/monitors/monitor_trigger.go @@ -10,11 +10,6 @@ import ( "github.com/urfave/cli/v3" ) -var ( - noWait bool - noResult bool -) - func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) error { if monitorId == "" { @@ -22,7 +17,7 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er } fmt.Println("Waiting for the result...") - url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s/trigger", monitorId) + url := fmt.Sprintf("%s/monitor/%s/trigger", APIBaseURL, monitorId) req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { @@ -39,7 +34,10 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var r MonitorTriggerResponse err = json.Unmarshal(body, &r) diff --git a/internal/monitors/monitor_update.go b/internal/monitors/monitor_update.go index 849e617..2bfcf9e 100644 --- a/internal/monitors/monitor_update.go +++ b/internal/monitors/monitor_update.go @@ -12,11 +12,14 @@ import ( func UpdateMonitor(httpClient *http.Client, apiKey string, id int, monitor config.Monitor) (Monitor, error) { - url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s/%d", monitor.Kind, id) + url := fmt.Sprintf("%s/monitor/%s/%d", APIBaseURL, monitor.Kind, id) payloadBuf := new(bytes.Buffer) json.NewEncoder(payloadBuf).Encode(monitor) - req, _ := http.NewRequest(http.MethodPut, url, payloadBuf) + req, err := http.NewRequest(http.MethodPut, url, payloadBuf) + if err != nil { + return Monitor{}, fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) req.Header.Add("Content-Type", "application/json") @@ -31,7 +34,10 @@ func UpdateMonitor(httpClient *http.Client, apiKey string, id int, monitor confi } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return Monitor{}, fmt.Errorf("failed to read response body: %w", err) + } var monitors Monitor err = json.Unmarshal(body, &monitors) diff --git a/internal/monitors/monitor_update_test.go b/internal/monitors/monitor_update_test.go new file mode 100644 index 0000000..4b7191a --- /dev/null +++ b/internal/monitors/monitor_update_test.go @@ -0,0 +1,207 @@ +package monitors_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openstatusHQ/cli/internal/config" + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_UpdateMonitor(t *testing.T) { + t.Parallel() + + t.Run("Update monitor successfully", func(t *testing.T) { + body := `{ + "id": 123, + "name": "Updated Monitor", + "url": "https://example.com", + "periodicity": "5m", + "method": "GET", + "regions": ["iad", "ams", "syd"], + "active": true, + "public": false, + "timeout": 45000, + "body": "", + "retry": 5, + "jobType": "http" + }` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPut { + t.Errorf("Expected PUT method, got %s", req.Method) + } + if req.Header.Get("x-openstatus-key") != "test-api-key" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type header, got %s", req.Header.Get("Content-Type")) + } + expectedURL := "https://api.openstatus.dev/v1/monitor/http/123" + if req.URL.String() != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Updated Monitor", + Active: true, + Frequency: config.The5M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad, config.Ams, config.Syd}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + Retry: 5, + } + + result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 123 { + t.Errorf("Expected ID 123, got %d", result.ID) + } + if result.Name != "Updated Monitor" { + t.Errorf("Expected name 'Updated Monitor', got %s", result.Name) + } + }) + + t.Run("Update monitor fails with non-200 status", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "bad request"}`))), + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Test Monitor", + Kind: config.HTTP, + } + + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + if err == nil { + t.Error("Expected error for non-200 status, got nil") + } + }) + + t.Run("Update monitor returns correct Monitor struct", func(t *testing.T) { + body := `{ + "id": 789, + "name": "Full Updated Monitor", + "url": "https://updated.example.com", + "periodicity": "30m", + "description": "Updated description", + "method": "PUT", + "regions": ["lhr", "fra"], + "active": false, + "public": true, + "timeout": 60000, + "degraded_after": 10000, + "body": "{\"updated\": true}", + "headers": [{"key": "Authorization", "value": "Bearer token"}], + "assertions": [{"type": "statusCode", "compare": "eq", "target": 201}], + "retry": 1, + "jobType": "http" + }` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Full Updated Monitor", + Kind: config.HTTP, + } + + result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 789, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expected := monitors.Monitor{ + ID: 789, + Name: "Full Updated Monitor", + URL: "https://updated.example.com", + Periodicity: "30m", + Description: "Updated description", + Method: "PUT", + Regions: []string{"lhr", "fra"}, + Active: false, + Public: true, + Timeout: 60000, + DegradedAfter: 10000, + Body: "{\"updated\": true}", + Headers: []monitors.Header{{Key: "Authorization", Value: "Bearer token"}}, + Assertions: []monitors.Assertion{{Type: "statusCode", Compare: "eq", Target: float64(201)}}, + Retry: 1, + JobType: "http", + } + + if !cmp.Equal(expected, result) { + t.Errorf("Expected %+v, got %+v", expected, result) + } + }) + + t.Run("Update TCP monitor uses correct URL", func(t *testing.T) { + body := `{ + "id": 100, + "name": "TCP Monitor", + "url": "example.com:443", + "periodicity": "1m", + "method": "", + "regions": ["iad"], + "active": true, + "public": false, + "timeout": 10000, + "body": "", + "retry": 0, + "jobType": "tcp" + }` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + expectedURL := "https://api.openstatus.dev/v1/monitor/tcp/100" + if req.URL.String() != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "TCP Monitor", + Kind: config.TCP, + } + + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 100, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) +} diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 152efba..cb941fe 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -6,6 +6,9 @@ import ( "github.com/urfave/cli/v3" ) +// APIBaseURL is the base URL for the OpenStatus API +const APIBaseURL = "https://api.openstatus.dev/v1" + type Monitor struct { ID int `json:"id"` Name string `json:"name"` diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index 91c7829..a79496e 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -15,9 +15,12 @@ import ( var allMonitor bool func ListMonitors(httpClient *http.Client, apiKey string) error { - url := "https://api.openstatus.dev/v1/monitor" + url := APIBaseURL + "/monitor" - req, _ := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) res, err := httpClient.Do(req) if err != nil { @@ -29,7 +32,10 @@ func ListMonitors(httpClient *http.Client, apiKey string) error { } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var monitors []Monitor err = json.Unmarshal(body, &monitors) if err != nil { diff --git a/internal/monitors/monitors_test.go b/internal/monitors/monitors_test.go index d0ea92c..1c43ddf 100644 --- a/internal/monitors/monitors_test.go +++ b/internal/monitors/monitors_test.go @@ -1,6 +1,11 @@ package monitors_test -import "net/http" +import ( + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/monitors" +) type interceptorHTTPClient struct { f func(req *http.Request) (*http.Response, error) @@ -15,3 +20,53 @@ func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { Transport: i, } } + +func Test_MonitorsCmd(t *testing.T) { + t.Parallel() + + t.Run("Returns valid command", func(t *testing.T) { + cmd := monitors.MonitorsCmd() + + if cmd == nil { + t.Fatal("Expected non-nil command") + } + + if cmd.Name != "monitors" { + t.Errorf("Expected command name 'monitors', got %s", cmd.Name) + } + + if cmd.Usage != "Manage your monitors" { + t.Errorf("Expected usage 'Manage your monitors', got %s", cmd.Usage) + } + }) + + t.Run("Has expected subcommands", func(t *testing.T) { + cmd := monitors.MonitorsCmd() + + if len(cmd.Commands) != 7 { + t.Errorf("Expected 7 subcommands, got %d", len(cmd.Commands)) + } + + expectedSubcommands := map[string]bool{ + "apply": false, + "create": false, + "delete": false, + "import": false, + "info": false, + "list": false, + "trigger": false, + } + + for _, subcmd := range cmd.Commands { + if _, exists := expectedSubcommands[subcmd.Name]; exists { + expectedSubcommands[subcmd.Name] = true + } + } + + for name, found := range expectedSubcommands { + if !found { + t.Errorf("Expected subcommand '%s' not found", name) + } + } + }) +} diff --git a/internal/run/run.go b/internal/run/run.go index 48af7bc..599f9db 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -26,7 +26,7 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("Monitor ID is required") } - url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s/run", monitorId) + url := fmt.Sprintf("%s/monitor/%s/run", monitors.APIBaseURL, monitorId) httpClient.Timeout = 2 * time.Minute @@ -47,7 +47,10 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er } defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var result []json.RawMessage err = json.Unmarshal(body, &result) diff --git a/internal/whoami/whoami.go b/internal/whoami/whoami.go index 2c14890..dda2ae7 100644 --- a/internal/whoami/whoami.go +++ b/internal/whoami/whoami.go @@ -17,9 +17,12 @@ type Whoami struct { } func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { - url := "https://api.openstatus.dev/v1/whoami" + url := "https://api.openstatus.dev/v1/whoami" // Using monitors.APIBaseURL would create circular import - req, _ := http.NewRequest("GET", url, nil) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } req.Header.Add("x-openstatus-key", apiKey) res, err := httpClient.Do(req) if err != nil { @@ -30,7 +33,10 @@ func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { if res.StatusCode != http.StatusOK { return fmt.Errorf("Failed to get workspace information") } - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } var whoami Whoami err = json.Unmarshal(body, &whoami) if err != nil {