From 0b1d12f7f3ea4542fb18d9c81d01ff5f809dae2c Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 16:21:20 +0000 Subject: [PATCH 1/7] feat: refactor spinner implementation and add tests for raw output mode --- cmd/anomalies.go | 7 +++--- cmd/commands_test.go | 8 ++++++- cmd/gateways.go | 14 ++++++------ cmd/sensors.go | 6 ++--- cmd/shell_test.go | 30 +++++++++++++++++++++++++ cmd/spinner.go | 52 ++++++++++++++++++++++++++++++++++++++++++++ cmd/spinner_test.go | 33 ++++++++++++++++++++++++++++ main_test.go | 16 ++++++++++++++ 8 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 cmd/shell_test.go create mode 100644 cmd/spinner.go create mode 100644 cmd/spinner_test.go create mode 100644 main_test.go diff --git a/cmd/anomalies.go b/cmd/anomalies.go index 6bc3715..77a5cfd 100644 --- a/cmd/anomalies.go +++ b/cmd/anomalies.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/NoTIPswe/notip-simulator-cli/internal/client" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -24,7 +23,7 @@ var anomaliesDisconnectCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { duration, _ := cmd.Flags().GetInt("duration") - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Triggering disconnect anomaly on gateway %s (%ds)...", args[0], duration), ) if err := client.New(simulatorURL).Disconnect(args[0], duration); err != nil { @@ -46,7 +45,7 @@ var anomaliesNetworkDegradationCmd = &cobra.Command{ duration, _ := cmd.Flags().GetInt("duration") loss, _ := cmd.Flags().GetFloat64("packet-loss") - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Triggering network-degradation on gateway %s (%ds, %.0f%% loss)...", args[0], duration, loss*100), ) @@ -77,7 +76,7 @@ var anomaliesOutlierCmd = &cobra.Command{ valuePtr = &v } - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Injecting outlier into sensor %d...", sensorID), ) if err := client.New(simulatorURL).InjectOutlier(sensorID, valuePtr); err != nil { diff --git a/cmd/commands_test.go b/cmd/commands_test.go index b05e122..7576a0a 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -14,8 +14,14 @@ import ( "github.com/spf13/pflag" ) -// TestMain disables all PTerm styling so ANSI codes don't pollute test output. +// TestMain disables styled/color output before any test runs. +// +// Command code uses startSpinner(), which returns a no-op spinner when +// pterm.RawOutput is enabled (set by DisableStyling). This avoids creating +// PTerm spinner goroutines in tests and prevents the known -race issue in +// pterm v0.12.79. func TestMain(m *testing.M) { + pterm.DisableOutput() pterm.DisableStyling() pterm.DisableColor() os.Exit(m.Run()) diff --git a/cmd/gateways.go b/cmd/gateways.go index e384a5f..39c1a27 100644 --- a/cmd/gateways.go +++ b/cmd/gateways.go @@ -26,7 +26,7 @@ var gatewaysListCmd = &cobra.Command{ Use: "list", Short: "List all gateways and their current status", RunE: func(cmd *cobra.Command, args []string) error { - spinner, _ := pterm.DefaultSpinner.Start("Fetching gateways...") + spinner := startSpinner("Fetching gateways...") c := client.New(simulatorURL) gateways, err := c.ListGateways() @@ -66,7 +66,7 @@ var gatewaysGetCmd = &cobra.Command{ Short: "Show details for a single gateway", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - spinner, _ := pterm.DefaultSpinner.Start("Fetching gateway " + args[0] + "...") + spinner := startSpinner("Fetching gateway " + args[0] + "...") c := client.New(simulatorURL) gw, err := c.GetGateway(args[0]) @@ -107,7 +107,7 @@ var gatewaysCreateCmd = &cobra.Command{ req.FirmwareVersion, _ = cmd.Flags().GetString("firmware") req.SendFrequencyMs, _ = cmd.Flags().GetInt("freq") - spinner, _ := pterm.DefaultSpinner.Start("Creating gateway...") + spinner := startSpinner("Creating gateway...") c := client.New(simulatorURL) gw, err := c.CreateGateway(req) if err != nil { @@ -134,7 +134,7 @@ var gatewaysBulkCmd = &cobra.Command{ req.FirmwareVersion, _ = cmd.Flags().GetString("firmware") req.SendFrequencyMs, _ = cmd.Flags().GetInt("freq") - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Creating %d gateway(s)...", req.Count), ) c := client.New(simulatorURL) @@ -174,7 +174,7 @@ var gatewaysStartCmd = &cobra.Command{ Short: "Start telemetry emission for a gateway", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - spinner, _ := pterm.DefaultSpinner.Start("Starting gateway " + args[0] + "...") + spinner := startSpinner("Starting gateway " + args[0] + "...") if err := client.New(simulatorURL).StartGateway(args[0]); err != nil { spinner.Fail("Failed to start gateway") return err @@ -191,7 +191,7 @@ var gatewaysStopCmd = &cobra.Command{ Short: "Stop telemetry emission for a gateway", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - spinner, _ := pterm.DefaultSpinner.Start("Stopping gateway " + args[0] + "...") + spinner := startSpinner("Stopping gateway " + args[0] + "...") if err := client.New(simulatorURL).StopGateway(args[0]); err != nil { spinner.Fail("Failed to stop gateway") return err @@ -208,7 +208,7 @@ var gatewaysDeleteCmd = &cobra.Command{ Short: "Delete a gateway by UUID", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - spinner, _ := pterm.DefaultSpinner.Start("Deleting gateway " + args[0] + "...") + spinner := startSpinner("Deleting gateway " + args[0] + "...") if err := client.New(simulatorURL).DeleteGateway(args[0]); err != nil { spinner.Fail("Failed to delete gateway") return err diff --git a/cmd/sensors.go b/cmd/sensors.go index e3cb68d..f5b6726 100644 --- a/cmd/sensors.go +++ b/cmd/sensors.go @@ -33,7 +33,7 @@ var sensorsAddCmd = &cobra.Command{ req.MaxRange, _ = cmd.Flags().GetFloat64("max") req.Algorithm, _ = cmd.Flags().GetString("algorithm") - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Adding %s sensor to gateway %d...", req.Type, gatewayID), ) sensor, err := client.New(simulatorURL).AddSensor(gatewayID, req) @@ -59,7 +59,7 @@ var sensorsListCmd = &cobra.Command{ return fmt.Errorf("gateway-id must be a numeric ID: %w", err) } - spinner, _ := pterm.DefaultSpinner.Start( + spinner := startSpinner( fmt.Sprintf("Fetching sensors for gateway %d...", gatewayID), ) sensors, err := client.New(simulatorURL).ListSensors(gatewayID) @@ -90,7 +90,7 @@ var sensorsDeleteCmd = &cobra.Command{ return fmt.Errorf("sensor-id must be a numeric ID: %w", err) } - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deleting sensor %d...", sensorID)) + spinner := startSpinner(fmt.Sprintf("Deleting sensor %d...", sensorID)) if err := client.New(simulatorURL).DeleteSensor(sensorID); err != nil { spinner.Fail("Failed to delete sensor") return err diff --git a/cmd/shell_test.go b/cmd/shell_test.go new file mode 100644 index 0000000..424ee37 --- /dev/null +++ b/cmd/shell_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "testing" + + "github.com/pterm/pterm" +) + +func TestPrintPrompt_RawOutput(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = true + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + out := captureStdout(t, printPrompt) + if out != "sim-cli> " { + t.Fatalf("unexpected prompt: %q", out) + } +} + +func TestPrintWelcomeBanner_RawOutput(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = true + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + _ = captureStdout(t, printWelcomeBanner) +} diff --git a/cmd/spinner.go b/cmd/spinner.go new file mode 100644 index 0000000..4e3f576 --- /dev/null +++ b/cmd/spinner.go @@ -0,0 +1,52 @@ +package cmd + +import "github.com/pterm/pterm" + +type spinner interface { + Success(text string) + Fail(text string) + Warning(text string) +} + +type noopSpinner struct{} + +func (noopSpinner) Success(string) {} +func (noopSpinner) Fail(string) {} +func (noopSpinner) Warning(string) {} + +type ptermSpinner struct { + inner *pterm.SpinnerPrinter +} + +func (s ptermSpinner) Success(text string) { + if s.inner == nil { + return + } + s.inner.Success(text) +} + +func (s ptermSpinner) Fail(text string) { + if s.inner == nil { + return + } + s.inner.Fail(text) +} + +func (s ptermSpinner) Warning(text string) { + if s.inner == nil { + return + } + s.inner.Warning(text) +} + +// startSpinner avoids spawning PTerm's spinner goroutine in RawOutput mode. +func startSpinner(text string) spinner { + if pterm.RawOutput { + return noopSpinner{} + } + sp, _ := pterm.DefaultSpinner.Start(text) + if sp == nil { + return noopSpinner{} + } + return ptermSpinner{inner: sp} +} diff --git a/cmd/spinner_test.go b/cmd/spinner_test.go new file mode 100644 index 0000000..dbd3408 --- /dev/null +++ b/cmd/spinner_test.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "testing" + + "github.com/pterm/pterm" +) + +func TestStartSpinner_RawOutputReturnsNoop(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = true + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + sp := startSpinner("test") + if _, ok := sp.(noopSpinner); !ok { + t.Fatalf("expected noopSpinner in raw output mode, got %T", sp) + } + + // No-op methods should be safe to call. + sp.Success("ok") + sp.Fail("err") + sp.Warning("warn") +} + +func TestPtermSpinner_NilInnerIsSafe(t *testing.T) { + sp := ptermSpinner{} + + sp.Success("ok") + sp.Fail("err") + sp.Warning("warn") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..b1b16da --- /dev/null +++ b/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "os" + "testing" +) + +func TestMain_HelpPath(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"sim-cli", "--help"} + t.Cleanup(func() { + os.Args = oldArgs + }) + + main() +} From ef8221cd2e143ef03e4e5920e442c67de1f1e9c1 Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 16:38:30 +0000 Subject: [PATCH 2/7] fix: update sonar project key --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index aac4f8a..434faab 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.projectKey=NoTIPswe_notip-simulator-cli +sonar.projectKey=NoTIPswe_notip-simulator-frontend sonar.organization=notipswe sonar.sources=cmd,internal From 55d50b42e82508f0cdeaac052dfa267ab43de30d Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 16:45:45 +0000 Subject: [PATCH 3/7] test: add tests for gateway error handling and main function execution --- internal/client/client_test.go | 49 ++++++++++++++++++++++++++++++++++ main.go | 7 +++-- main_test.go | 42 +++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index d710309..d23360a 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/NoTIPswe/notip-simulator-cli/internal/client" @@ -426,3 +427,51 @@ func TestInjectOutlier_SensorNotFound(t *testing.T) { t.Fatal("expected error on 404, got nil") } } + +func TestGetGateway_ErrorIncludesStatusAndBody(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("gateway id format is invalid")) + }) + + _, err := c.GetGateway("bad-id") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "backend returned 400") { + t.Fatalf("error should include status code, got: %v", err) + } + if !strings.Contains(err.Error(), "gateway id format is invalid") { + t.Fatalf("error should include backend body, got: %v", err) + } +} + +func TestCreateGateway_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("{invalid-json")) + }) + + _, err := c.CreateGateway(client.CreateGatewayRequest{ + FactoryID: "f-1", + FactoryKey: "k-1", + SerialNumber: "SN-1", + }) + if err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestListGateways_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + }) + + _, err := c.ListGateways() + if err == nil { + t.Fatal("expected decode error, got nil") + } +} diff --git a/main.go b/main.go index d72d8e4..c2e124d 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,11 @@ import ( "github.com/NoTIPswe/notip-simulator-cli/cmd" ) +var execute = cmd.Execute +var osExit = os.Exit + func main() { - if err := cmd.Execute(); err != nil { - os.Exit(1) + if err := execute(); err != nil { + osExit(1) } } diff --git a/main_test.go b/main_test.go index b1b16da..3e42498 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "testing" ) @@ -14,3 +15,44 @@ func TestMain_HelpPath(t *testing.T) { main() } + +func TestMain_DoesNotExitOnSuccess(t *testing.T) { + oldExecute := execute + oldOsExit := osExit + t.Cleanup(func() { + execute = oldExecute + osExit = oldOsExit + }) + + execute = func() error { return nil } + osExit = func(code int) { + t.Fatalf("osExit should not be called on success, got code %d", code) + } + + main() +} + +func TestMain_ExitsWithCode1OnError(t *testing.T) { + oldExecute := execute + oldOsExit := osExit + t.Cleanup(func() { + execute = oldExecute + osExit = oldOsExit + }) + + execute = func() error { return errors.New("boom") } + + called := false + osExit = func(code int) { + called = true + if code != 1 { + t.Fatalf("osExit code = %d, want 1", code) + } + } + + main() + + if !called { + t.Fatal("expected osExit to be called") + } +} From a483680a6f9f9346f1a7ebc8e5feeea9ccab62d6 Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 19:55:09 +0000 Subject: [PATCH 4/7] fix: improve noopSpinner methods with comments for clarity will have to review it tomorrow --- cmd/commands_test.go | 287 +++++++++++++++++++++++++++++++++++++++---- cmd/spinner.go | 14 ++- 2 files changed, 277 insertions(+), 24 deletions(-) diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 7576a0a..24dc051 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -27,6 +27,22 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +// ── test constants ──────────────────────────────────────────────────────────── + +const ( + testGatewayUUID = "uuid-1" + cmdNetDegradation = "network-degradation" + fmtUnexpectedPath = "unexpected path: %s" + fmtUnexpectedRequest = "unexpected request: %s %s" + testFlagFactoryID = "--factory-id" + testFlagFactoryKey = "--factory-key" + testFlagDuration = "--duration" + bodyNotFound = "not found" + errExpected404 = "expected error on 404" + fmtPipeErr = "pipe: %v" + fmtWriteErr = "write: %v" +) + // ── helpers ─────────────────────────────────────────────────────────────────── // resetAllFlags walks the entire command tree and resets every flag to its @@ -87,7 +103,7 @@ func TestCommandTree(t *testing.T) { {"sensors", "list"}, {"sensors", "delete"}, {"anomalies", "disconnect"}, - {"anomalies", "network-degradation"}, + {"anomalies", cmdNetDegradation}, {"anomalies", "outlier"}, } @@ -115,7 +131,7 @@ func TestGatewaysCreate_MissingRequiredFlags(t *testing.T) { } func TestGatewaysBulk_MissingCount(t *testing.T) { - if err := runCmd("gateways", "bulk", "--factory-id", "f", "--factory-key", "k"); err == nil { + if err := runCmd("gateways", "bulk", testFlagFactoryID, "f", testFlagFactoryKey, "k"); err == nil { t.Error("expected error when --count is missing") } } @@ -127,13 +143,13 @@ func TestSensorsAdd_MissingFlags(t *testing.T) { } func TestAnomaliesDisconnect_MissingDuration(t *testing.T) { - if err := runCmd("anomalies", "disconnect", "uuid-1"); err == nil { + if err := runCmd("anomalies", "disconnect", testGatewayUUID); err == nil { t.Error("expected error when --duration is missing") } } func TestAnomaliesNetworkDegradation_MissingDuration(t *testing.T) { - if err := runCmd("anomalies", "network-degradation", "uuid-1"); err == nil { + if err := runCmd("anomalies", cmdNetDegradation, testGatewayUUID); err == nil { t.Error("expected error when --duration is missing") } } @@ -179,11 +195,11 @@ func TestAnomaliesOutlier_NonNumericID(t *testing.T) { func TestGatewaysList_Integration(t *testing.T) { gateways := []map[string]any{ - {"id": 1, "managementGatewayId": "uuid-1", "status": "online", "model": "X", "serialNumber": "SN1", "sendFrequencyMs": 1000, "tenantId": "t1"}, + {"id": 1, "managementGatewayId": testGatewayUUID, "status": "online", "model": "X", "serialNumber": "SN1", "sendFrequencyMs": 1000, "tenantId": "t1"}, } newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/sim/gateways" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) } writeJSON(w, http.StatusOK, gateways) }) @@ -205,11 +221,11 @@ func TestGatewaysList_ServerError(t *testing.T) { func TestGatewaysStart_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-1/start" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("gateways", "start", "uuid-1"); err != nil { + if err := runCmd("gateways", "start", testGatewayUUID); err != nil { t.Fatalf("gateways start failed: %v", err) } } @@ -217,11 +233,11 @@ func TestGatewaysStart_Integration(t *testing.T) { func TestGatewaysStop_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-1/stop" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("gateways", "stop", "uuid-1"); err != nil { + if err := runCmd("gateways", "stop", testGatewayUUID); err != nil { t.Fatalf("gateways stop failed: %v", err) } } @@ -229,11 +245,11 @@ func TestGatewaysStop_Integration(t *testing.T) { func TestGatewaysDelete_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path != "/sim/gateways/uuid-1" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("gateways", "delete", "uuid-1"); err != nil { + if err := runCmd("gateways", "delete", testGatewayUUID); err != nil { t.Fatalf("gateways delete failed: %v", err) } } @@ -244,7 +260,7 @@ func TestSensorsList_Integration(t *testing.T) { } newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/5/sensors" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } writeJSON(w, http.StatusOK, sensors) }) @@ -256,7 +272,7 @@ func TestSensorsList_Integration(t *testing.T) { func TestSensorsDelete_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path != "/sim/sensors/99" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) } w.WriteHeader(http.StatusNoContent) }) @@ -268,7 +284,7 @@ func TestSensorsDelete_Integration(t *testing.T) { func TestAnomaliesDisconnect_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-1/anomaly/disconnect" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) @@ -277,7 +293,7 @@ func TestAnomaliesDisconnect_Integration(t *testing.T) { } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("anomalies", "disconnect", "uuid-1", "--duration", "3"); err != nil { + if err := runCmd("anomalies", "disconnect", testGatewayUUID, testFlagDuration, "3"); err != nil { t.Fatalf("anomalies disconnect failed: %v", err) } } @@ -285,7 +301,7 @@ func TestAnomaliesDisconnect_Integration(t *testing.T) { func TestAnomaliesNetworkDegradation_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-1/anomaly/network-degradation" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) @@ -297,7 +313,7 @@ func TestAnomaliesNetworkDegradation_Integration(t *testing.T) { } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("anomalies", "network-degradation", "uuid-1", "--duration", "10", "--packet-loss", "0.3"); err != nil { + if err := runCmd("anomalies", cmdNetDegradation, testGatewayUUID, testFlagDuration, "10", "--packet-loss", "0.3"); err != nil { t.Fatalf("anomalies network-degradation failed: %v", err) } } @@ -305,7 +321,7 @@ func TestAnomaliesNetworkDegradation_Integration(t *testing.T) { func TestAnomaliesOutlier_Integration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/sensors/42/anomaly/outlier" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(fmtUnexpectedPath, r.URL.Path) } var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) @@ -319,13 +335,242 @@ func TestAnomaliesOutlier_Integration(t *testing.T) { } } +// ── Error paths (spinner.Fail + return err) ─────────────────────────────────── + +func TestGatewaysList_EmptyResult(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []any{}) + }) + if err := runCmd("gateways", "list"); err != nil { + t.Fatalf("gateways list empty failed: %v", err) + } +} + +func TestGatewaysGet_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("gateways", "get", testGatewayUUID); err == nil { + t.Error(errExpected404) + } +} + +func TestGatewaysCreate_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad request", http.StatusBadRequest) + }) + err := runCmd("gateways", "create", testFlagFactoryID, "f", testFlagFactoryKey, "k", "--serial", "SN") + if err == nil { + t.Error("expected error when server returns 400") + } +} + +func TestGatewaysBulk_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server error", http.StatusInternalServerError) + }) + err := runCmd("gateways", "bulk", "--count", "2", testFlagFactoryID, "f", testFlagFactoryKey, "k") + if err == nil { + t.Error("expected error when bulk server returns 500") + } +} + +func TestGatewaysBulk_PartialErrors(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusMultiStatus, map[string]any{ + "gateways": []any{map[string]any{"id": 1}}, + "errors": []any{"", "factory key mismatch"}, + }) + }) + err := runCmd("gateways", "bulk", "--count", "2", testFlagFactoryID, "f", testFlagFactoryKey, "k") + if err != nil { + t.Fatalf("bulk partial error should succeed at cmd level: %v", err) + } +} + +func TestGatewaysStart_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "conflict", http.StatusConflict) + }) + if err := runCmd("gateways", "start", testGatewayUUID); err == nil { + t.Error("expected error on 409") + } +} + +func TestGatewaysStop_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("gateways", "stop", testGatewayUUID); err == nil { + t.Error(errExpected404) + } +} + +func TestGatewaysDelete_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("gateways", "delete", testGatewayUUID); err == nil { + t.Error(errExpected404) + } +} + +func TestSensorsAdd_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + err := runCmd("sensors", "add", "5", "--type", "temperature", "--min", "0", "--max", "100", "--algorithm", "constant") + if err == nil { + t.Error(errExpected404) + } +} + +func TestSensorsList_EmptyResult(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []any{}) + }) + if err := runCmd("sensors", "list", "5"); err != nil { + t.Fatalf("sensors list empty failed: %v", err) + } +} + +func TestSensorsList_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("sensors", "list", "5"); err == nil { + t.Error(errExpected404) + } +} + +func TestSensorsDelete_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("sensors", "delete", "99"); err == nil { + t.Error(errExpected404) + } +} + +func TestAnomaliesDisconnect_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("anomalies", "disconnect", testGatewayUUID, testFlagDuration, "5"); err == nil { + t.Error(errExpected404) + } +} + +func TestAnomaliesNetworkDegradation_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("anomalies", cmdNetDegradation, testGatewayUUID, testFlagDuration, "5"); err == nil { + t.Error(errExpected404) + } +} + +func TestAnomaliesOutlier_ServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, bodyNotFound, http.StatusNotFound) + }) + if err := runCmd("anomalies", "outlier", "42"); err == nil { + t.Error(errExpected404) + } +} + +// ── Shell edge cases ────────────────────────────────────────────────────────── + +func TestShellQuitCommand(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf(fmtPipeErr, err) + } + originalStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = originalStdin }) + t.Cleanup(func() { _ = r.Close() }) + + if _, err := w.WriteString("quit\n"); err != nil { + t.Fatalf(fmtWriteErr, err) + } + _ = w.Close() + + if err := runCmd("shell"); err != nil { + t.Fatalf("shell quit failed: %v", err) + } +} + +func TestShellEmptyLineAndNestedShell(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf(fmtPipeErr, err) + } + originalStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = originalStdin }) + t.Cleanup(func() { _ = r.Close() }) + + if _, err := w.WriteString("\nshell\nexit\n"); err != nil { + t.Fatalf(fmtWriteErr, err) + } + _ = w.Close() + + if err := runCmd("shell"); err != nil { + t.Fatalf("shell with empty line and nested shell attempt failed: %v", err) + } +} + +func TestShellEOF(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf(fmtPipeErr, err) + } + originalStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = originalStdin }) + t.Cleanup(func() { _ = r.Close() }) + + // Close writer immediately to trigger EOF. + _ = w.Close() + + if err := runCmd("shell"); err != nil { + t.Fatalf("shell EOF failed: %v", err) + } +} + +func TestShellCommandError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf(fmtPipeErr, err) + } + originalStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = originalStdin }) + t.Cleanup(func() { _ = r.Close() }) + + if _, err := w.WriteString("gateways list\nexit\n"); err != nil { + t.Fatalf(fmtWriteErr, err) + } + _ = w.Close() + + // Shell should not return an error even when a sub-command fails. + if err := runCmd("shell"); err != nil { + t.Fatalf("shell should swallow sub-command errors: %v", err) + } +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() originalStdout := os.Stdout r, w, err := os.Pipe() if err != nil { - t.Fatalf("pipe: %v", err) + t.Fatalf(fmtPipeErr, err) } os.Stdout = w t.Cleanup(func() { @@ -363,7 +608,7 @@ func TestShellExitImmediately(t *testing.T) { originalStdin := os.Stdin r, w, err := os.Pipe() if err != nil { - t.Fatalf("pipe: %v", err) + t.Fatalf(fmtPipeErr, err) } os.Stdin = r t.Cleanup(func() { diff --git a/cmd/spinner.go b/cmd/spinner.go index 4e3f576..fe21326 100644 --- a/cmd/spinner.go +++ b/cmd/spinner.go @@ -10,9 +10,17 @@ type spinner interface { type noopSpinner struct{} -func (noopSpinner) Success(string) {} -func (noopSpinner) Fail(string) {} -func (noopSpinner) Warning(string) {} +func (noopSpinner) Success(string) { + // no-op: spinner is disabled in raw output mode +} + +func (noopSpinner) Fail(string) { + // no-op: spinner is disabled in raw output mode +} + +func (noopSpinner) Warning(string) { + // no-op: spinner is disabled in raw output mode +} type ptermSpinner struct { inner *pterm.SpinnerPrinter From 9056d368209afec486ad90c0fb14dc92a2e4a49a Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 20:04:10 +0000 Subject: [PATCH 5/7] fix: add tests for handling invalid JSON responses in client methods --- cmd/shell_test.go | 24 +++++++++++++++ cmd/spinner_test.go | 43 ++++++++++++++++++++++++++ internal/client/client_test.go | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/cmd/shell_test.go b/cmd/shell_test.go index 424ee37..1da6f60 100644 --- a/cmd/shell_test.go +++ b/cmd/shell_test.go @@ -1,6 +1,7 @@ package cmd import ( + "strings" "testing" "github.com/pterm/pterm" @@ -28,3 +29,26 @@ func TestPrintWelcomeBanner_RawOutput(t *testing.T) { _ = captureStdout(t, printWelcomeBanner) } + +func TestPrintPrompt_NonRawOutput(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = false + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + out := captureStdout(t, printPrompt) + if !strings.Contains(out, "sim-cli") { + t.Fatalf("unexpected prompt: %q", out) + } +} + +func TestPrintWelcomeBanner_NonRawOutput(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = false + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + _ = captureStdout(t, printWelcomeBanner) +} diff --git a/cmd/spinner_test.go b/cmd/spinner_test.go index dbd3408..095d0af 100644 --- a/cmd/spinner_test.go +++ b/cmd/spinner_test.go @@ -1,6 +1,7 @@ package cmd import ( + "reflect" "testing" "github.com/pterm/pterm" @@ -31,3 +32,45 @@ func TestPtermSpinner_NilInnerIsSafe(t *testing.T) { sp.Fail("err") sp.Warning("warn") } + +func TestNoopSpinner_DirectMethods(t *testing.T) { + var sp noopSpinner + + sp.Success("ok") + sp.Fail("err") + sp.Warning("warn") +} + +func TestStartSpinner_NonRawReturnsPtermSpinner(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = false + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + sp := startSpinner("test") + + if reflect.TypeOf(sp) != reflect.TypeOf(ptermSpinner{}) { + t.Fatalf("expected ptermSpinner in non-raw mode, got %T", sp) + } + + sp.Success("ok") +} + +func TestPtermSpinner_WithInnerIsSafe(t *testing.T) { + prevRaw := pterm.RawOutput + pterm.RawOutput = false + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + raw := startSpinner("test") + sp, ok := raw.(ptermSpinner) + if !ok { + t.Skipf("expected ptermSpinner in non-raw mode, got %T", raw) + } + + sp.Success("ok") + sp.Fail("err") + sp.Warning("warn") +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go index d23360a..20fa413 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -475,3 +475,59 @@ func TestListGateways_InvalidJSONResponse(t *testing.T) { t.Fatal("expected decode error, got nil") } } + +func TestBulkCreateGateways_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("not-json")) + }) + + _, err := c.BulkCreateGateways(client.BulkCreateGatewaysRequest{ + Count: 1, + FactoryID: "f-1", + FactoryKey: "k-1", + }) + if err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestGetGateway_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + }) + + _, err := c.GetGateway("uuid-1") + if err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestAddSensor_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("not-json")) + }) + + _, err := c.AddSensor(1, client.AddSensorRequest{Type: "temperature", Algorithm: "constant"}) + if err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestListSensors_InvalidJSONResponse(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + }) + + _, err := c.ListSensors(1) + if err == nil { + t.Fatal("expected decode error, got nil") + } +} From 8d34fcd0aaea1e1c7fd103f3ddeaa17062fbb1a0 Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 20:08:43 +0000 Subject: [PATCH 6/7] fix: updated again tests --- cmd/spinner_test.go | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/cmd/spinner_test.go b/cmd/spinner_test.go index 095d0af..45ae8cd 100644 --- a/cmd/spinner_test.go +++ b/cmd/spinner_test.go @@ -1,7 +1,6 @@ package cmd import ( - "reflect" "testing" "github.com/pterm/pterm" @@ -40,37 +39,3 @@ func TestNoopSpinner_DirectMethods(t *testing.T) { sp.Fail("err") sp.Warning("warn") } - -func TestStartSpinner_NonRawReturnsPtermSpinner(t *testing.T) { - prevRaw := pterm.RawOutput - pterm.RawOutput = false - t.Cleanup(func() { - pterm.RawOutput = prevRaw - }) - - sp := startSpinner("test") - - if reflect.TypeOf(sp) != reflect.TypeOf(ptermSpinner{}) { - t.Fatalf("expected ptermSpinner in non-raw mode, got %T", sp) - } - - sp.Success("ok") -} - -func TestPtermSpinner_WithInnerIsSafe(t *testing.T) { - prevRaw := pterm.RawOutput - pterm.RawOutput = false - t.Cleanup(func() { - pterm.RawOutput = prevRaw - }) - - raw := startSpinner("test") - sp, ok := raw.(ptermSpinner) - if !ok { - t.Skipf("expected ptermSpinner in non-raw mode, got %T", raw) - } - - sp.Success("ok") - sp.Fail("err") - sp.Warning("warn") -} From 5504fd0f9f7de5f0a0dcebeadefe4211bfe6d69d Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Fri, 3 Apr 2026 20:17:53 +0000 Subject: [PATCH 7/7] fix: add mustMarkRequired function and tests for flag requirements --- cmd/anomalies.go | 19 ++++++++++-------- cmd/anomalies_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++ cmd/commands_test.go | 11 +++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 cmd/anomalies_test.go diff --git a/cmd/anomalies.go b/cmd/anomalies.go index 77a5cfd..bfe619f 100644 --- a/cmd/anomalies.go +++ b/cmd/anomalies.go @@ -14,6 +14,15 @@ var anomaliesCmd = &cobra.Command{ Short: "Trigger anomaly scenarios on gateways and sensors", } +var exitProcess = os.Exit + +func mustMarkRequired(cmd *cobra.Command, flagName string) { + if err := cmd.MarkFlagRequired(flagName); err != nil { + fmt.Fprintln(os.Stderr, err) + exitProcess(1) + } +} + // ── disconnect ──────────────────────────────────────────────────────────────── var anomaliesDisconnectCmd = &cobra.Command{ @@ -100,18 +109,12 @@ func init() { // disconnect flags anomaliesDisconnectCmd.Flags().Int("duration", 0, "Disconnect duration in seconds (required, must be > 0)") - if err := anomaliesDisconnectCmd.MarkFlagRequired("duration"); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + mustMarkRequired(anomaliesDisconnectCmd, "duration") // network-degradation flags anomaliesNetworkDegradationCmd.Flags().Int("duration", 0, "Duration in seconds (required)") anomaliesNetworkDegradationCmd.Flags().Float64("packet-loss", 0, "Packet loss fraction 0–1 (e.g. 0.3 = 30%); omit to use backend default of 0.3") - if err := anomaliesNetworkDegradationCmd.MarkFlagRequired("duration"); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + mustMarkRequired(anomaliesNetworkDegradationCmd, "duration") // outlier flags anomaliesOutlierCmd.Flags().Float64("value", 0, "Outlier value to inject; omit to let the backend decide") diff --git a/cmd/anomalies_test.go b/cmd/anomalies_test.go new file mode 100644 index 0000000..fd8de60 --- /dev/null +++ b/cmd/anomalies_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMustMarkRequired_Success(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("duration", "", "") + + mustMarkRequired(cmd, "duration") + + f := cmd.Flags().Lookup("duration") + if f == nil { + t.Fatal("duration flag should exist") + } + if f.Annotations == nil || len(f.Annotations[cobra.BashCompOneRequiredFlag]) == 0 { + t.Fatal("duration flag should be marked as required") + } +} + +func TestMustMarkRequired_ErrorTriggersExit(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + called := false + code := 0 + + prevExit := exitProcess + exitProcess = func(c int) { + called = true + code = c + } + t.Cleanup(func() { + exitProcess = prevExit + }) + + mustMarkRequired(cmd, "missing-flag") + + if !called { + t.Fatal("expected exit function to be called") + } + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } +} diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 24dc051..5ae1cc7 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -9,6 +9,7 @@ import ( "os" "testing" + "github.com/NoTIPswe/notip-simulator-cli/internal/client" "github.com/pterm/pterm" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -672,3 +673,13 @@ func TestStatusStyleVariants(t *testing.T) { t.Fatalf("unknown style = %q, want %q", got, "unknown") } } + +func TestPrintSensorTable_EmptySlice_NoOutput(t *testing.T) { + out := captureStdout(t, func() { + printSensorTable([]client.Sensor{}) + }) + + if out != "" { + t.Fatalf("expected no output for empty sensor table, got %q", out) + } +}