diff --git a/README.md b/README.md index 99c3a7c..62efc6a 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,43 @@ docker compose run --rm sim-cli --help # List all gateways (requires -it for styled output) docker compose run --rm -it sim-cli gateways list -# Create 5 gateways for a tenant -docker compose run --rm sim-cli gateways create --count 5 --tenant - -# Delete a gateway by ID -docker compose run --rm sim-cli gateways delete +# Create a single gateway +docker compose run --rm sim-cli gateways create \ + --factory-id FAC-001 \ + --factory-key KEY-001 \ + --model GW-X \ + --firmware 1.0.0 \ + --freq 1000 + +# Bulk create 5 gateways +docker compose run --rm sim-cli gateways bulk \ + --count 5 \ + --factory-id FAC-001 \ + --factory-key KEY-001 \ + --model GW-X \ + --firmware 1.0.0 \ + --freq 1000 + +# Delete a gateway by UUID +docker compose run --rm sim-cli gateways delete ``` ### Sensors ```bash -# Add a temperature sensor to a gateway -docker compose run --rm sim-cli sensors add --type temperature --min 20.0 --max 80.0 +# Add a temperature sensor to a gateway (numeric ID or UUID) +docker compose run --rm sim-cli sensors add \ + --type temperature \ + --min 20.0 \ + --max 80.0 \ + --algorithm uniform_random ``` ### Anomalies ```bash # Trigger a disconnect anomaly on a gateway -docker compose run --rm sim-cli anomalies disconnect +docker compose run --rm sim-cli anomalies disconnect --duration 10 ``` ## Docker Compose configuration diff --git a/cmd/anomalies.go b/cmd/anomalies.go index 494903a..b01065b 100644 --- a/cmd/anomalies.go +++ b/cmd/anomalies.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "github.com/NoTIPswe/notip-simulator-cli/internal/client" "github.com/spf13/cobra" @@ -70,14 +69,11 @@ var anomaliesNetworkDegradationCmd = &cobra.Command{ // ── outlier ─────────────────────────────────────────────────────────────────── var anomaliesOutlierCmd = &cobra.Command{ - Use: "outlier ", - Short: "Inject an outlier reading into a sensor (uses the numeric sensor ID)", + Use: "outlier ", + Short: "Inject an outlier reading into a sensor", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - sensorID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("sensor-id must be a numeric ID: %w", err) - } + sensorID := args[0] var valuePtr *float64 if cmd.Flags().Changed("value") { @@ -86,13 +82,13 @@ var anomaliesOutlierCmd = &cobra.Command{ } spinner := startSpinner( - fmt.Sprintf("Injecting outlier into sensor %d...", sensorID), + fmt.Sprintf("Injecting outlier into sensor %s...", sensorID), ) if err := client.New(simulatorURL).WithContext(cmd.Context()).InjectOutlier(sensorID, valuePtr); err != nil { spinner.Fail("Failed to inject outlier") return err } - spinner.Success(fmt.Sprintf("Outlier injected into sensor %d", sensorID)) + spinner.Success(fmt.Sprintf("Outlier injected into sensor %s", sensorID)) return nil }, } diff --git a/cmd/commands_test.go b/cmd/commands_test.go index b41d583..8890b83 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -32,8 +32,10 @@ func TestMain(m *testing.M) { const ( testGatewayUUID = "uuid-1" + testGatewayPublicID = "gw-public-1" testSensorUUID = "s-uuid-1" cmdNetDegradation = "network-degradation" + pathGatewaysPrefix = "/sim/gateways/" fmtUnexpectedPath = "unexpected path: %s" fmtUnexpectedRequest = "unexpected request: %s %s" testFlagFactoryID = "--factory-id" @@ -41,12 +43,15 @@ const ( testFlagDuration = "--duration" testFlagType = "--type" testFlagAlgorithm = "--algorithm" + testFlagModel = "--model" + testFlagFirmware = "--firmware" + testFlagFreq = "--freq" bodyNotFound = "not found" errExpected404 = "expected error on 404" fmtPipeErr = "pipe: %v" fmtWriteErr = "write: %v" - pathGatewayUUID = "/sim/gateways/" + testGatewayUUID - pathGatewaySensors = "/sim/gateways/5/sensors" + pathGatewayUUID = pathGatewaysPrefix + testGatewayUUID + pathGatewaySensors = pathGatewaysPrefix + testGatewayPublicID + "/sensors" ) // ── helpers ─────────────────────────────────────────────────────────────────── @@ -130,14 +135,20 @@ func TestCommandTree(t *testing.T) { // ── Required-flag validation ────────────────────────────────────────────────── func TestGatewaysCreateMissingRequiredFlags(t *testing.T) { - // factory-id, factory-key, serial are all required + // all create flags are required if err := runCmd("gateways", "create"); err == nil { t.Error("expected error when required flags are missing") } } func TestGatewaysBulkMissingCount(t *testing.T) { - if err := runCmd("gateways", "bulk", testFlagFactoryID, "f", testFlagFactoryKey, "k"); err == nil { + if err := runCmd("gateways", "bulk", + testFlagFactoryID, "f", + testFlagFactoryKey, "k", + testFlagModel, "GW-X", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", + ); err == nil { t.Error("expected error when --count is missing") } } @@ -187,15 +198,15 @@ func TestSensorsAddInvalidGatewayIdentifier(t *testing.T) { } } -func TestSensorsDeleteNonNumericID(t *testing.T) { - if err := runCmd("sensors", "delete", "abc"); err == nil { - t.Error("expected error for non-numeric sensor ID") +func TestSensorsDeleteNoArgs(t *testing.T) { + if err := runCmd("sensors", "delete"); err == nil { + t.Error("expected error when sensor uuid arg is missing") } } -func TestAnomaliesOutlierNonNumericID(t *testing.T) { - if err := runCmd("anomalies", "outlier", "not-a-number"); err == nil { - t.Error("expected error for non-numeric sensor ID") +func TestAnomaliesOutlierNoArgs(t *testing.T) { + if err := runCmd("anomalies", "outlier"); err == nil { + t.Error("expected error when sensor uuid arg is missing") } } @@ -203,7 +214,7 @@ func TestAnomaliesOutlierNonNumericID(t *testing.T) { func TestGatewaysListIntegration(t *testing.T) { gateways := []map[string]any{ - {"id": 1, "managementGatewayId": testGatewayUUID, "status": "online", "model": "X", "serialNumber": "SN1", "sendFrequencyMs": 1000, "tenantId": "t1"}, + {"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID, "status": "online", "model": "X", "sendFrequencyMs": 1000, "tenantId": "t1"}, } newMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/sim/gateways" { @@ -264,28 +275,33 @@ func TestGatewaysDeleteIntegration(t *testing.T) { func TestSensorsListIntegration(t *testing.T) { sensors := []map[string]any{ - {"id": 1, "gatewayId": 5, "sensorId": testSensorUUID, "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "sine_wave"}, + {"id": testSensorUUID, "gatewayId": testGatewayPublicID, "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "sine_wave"}, } newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != pathGatewaySensors { + switch r.URL.Path { + case pathGatewayUUID: + writeJSON(w, http.StatusOK, map[string]any{"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID}) + case pathGatewaySensors: + writeJSON(w, http.StatusOK, sensors) + default: t.Errorf(fmtUnexpectedPath, r.URL.Path) } - writeJSON(w, http.StatusOK, sensors) }) - if err := runCmd("sensors", "list", "5"); err != nil { + if err := runCmd("sensors", "list", testGatewayUUID); err != nil { t.Fatalf("sensors list failed: %v", err) } } func TestSensorsListUUIDIntegration(t *testing.T) { + const publicID = "gw-public-uuid-test" sensors := []map[string]any{ - {"id": 1, "gatewayId": 5, "sensorId": "s-uuid-1", "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "sine_wave"}, + {"id": "s-uuid-1", "gatewayId": publicID, "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "sine_wave"}, } newMockServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case pathGatewayUUID: - writeJSON(w, http.StatusOK, map[string]any{"id": 5, "managementGatewayId": testGatewayUUID}) - case pathGatewaySensors: + writeJSON(w, http.StatusOK, map[string]any{"id": publicID, "managementGatewayId": testGatewayUUID}) + case "/sim/gateways/" + publicID + "/sensors": writeJSON(w, http.StatusOK, sensors) default: t.Errorf(fmtUnexpectedPath, r.URL.Path) @@ -300,10 +316,10 @@ func TestSensorsAddUUIDIntegration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case pathGatewayUUID: - writeJSON(w, http.StatusOK, map[string]any{"id": 5, "managementGatewayId": testGatewayUUID}) + writeJSON(w, http.StatusOK, map[string]any{"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID}) case pathGatewaySensors: writeJSON(w, http.StatusCreated, map[string]any{ - "id": 1, "gatewayId": 5, "sensorId": testSensorUUID, "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "constant", + "id": testSensorUUID, "gatewayId": testGatewayPublicID, "type": "temperature", "minRange": 0, "maxRange": 100, "algorithm": "constant", }) default: t.Errorf(fmtUnexpectedPath, r.URL.Path) @@ -317,12 +333,12 @@ func TestSensorsAddUUIDIntegration(t *testing.T) { func TestSensorsDeleteIntegration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/sim/sensors/99" { + if r.Method != http.MethodDelete || r.URL.Path != "/sim/sensors/"+testSensorUUID { t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("sensors", "delete", "99"); err != nil { + if err := runCmd("sensors", "delete", testSensorUUID); err != nil { t.Fatalf("sensors delete failed: %v", err) } } @@ -366,7 +382,7 @@ func TestAnomaliesNetworkDegradationIntegration(t *testing.T) { func TestAnomaliesOutlierIntegration(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sim/sensors/42/anomaly/outlier" { + if r.URL.Path != "/sim/sensors/sensor-42/anomaly/outlier" { t.Errorf(fmtUnexpectedPath, r.URL.Path) } var body map[string]any @@ -376,7 +392,7 @@ func TestAnomaliesOutlierIntegration(t *testing.T) { } w.WriteHeader(http.StatusNoContent) }) - if err := runCmd("anomalies", "outlier", "42", "--value", "999.9"); err != nil { + if err := runCmd("anomalies", "outlier", "sensor-42", "--value", "999.9"); err != nil { t.Fatalf("anomalies outlier failed: %v", err) } } @@ -405,7 +421,13 @@ func TestGatewaysCreateServerError(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") + err := runCmd("gateways", "create", + testFlagFactoryID, "f", + testFlagFactoryKey, "k", + testFlagModel, "GW-X", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", + ) if err == nil { t.Error("expected error when server returns 400") } @@ -415,7 +437,14 @@ func TestGatewaysBulkServerError(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") + err := runCmd("gateways", "bulk", + "--count", "2", + testFlagFactoryID, "f", + testFlagFactoryKey, "k", + testFlagModel, "GW-X", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", + ) if err == nil { t.Error("expected error when bulk server returns 500") } @@ -424,11 +453,18 @@ func TestGatewaysBulkServerError(t *testing.T) { func TestGatewaysBulkPartialErrors(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}}, + "gateways": []any{map[string]any{"id": "gw-1"}}, "errors": []any{"", "factory key mismatch"}, }) }) - err := runCmd("gateways", "bulk", "--count", "2", testFlagFactoryID, "f", testFlagFactoryKey, "k") + err := runCmd("gateways", "bulk", + "--count", "2", + testFlagFactoryID, "f", + testFlagFactoryKey, "k", + testFlagModel, "GW-X", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", + ) if err != nil { t.Fatalf("bulk partial error should succeed at cmd level: %v", err) } @@ -465,7 +501,30 @@ func TestSensorsAddServerError(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") + err := runCmd("sensors", "add", testGatewayUUID, "--type", "temperature", "--min", "0", "--max", "100", "--algorithm", "constant") + if err == nil { + t.Error(errExpected404) + } +} + +func TestSensorsAddFailsAfterGatewayLookup(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case pathGatewayUUID: + writeJSON(w, http.StatusOK, map[string]any{"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID}) + case pathGatewaySensors: + http.Error(w, bodyNotFound, http.StatusNotFound) + default: + t.Errorf(fmtUnexpectedPath, r.URL.Path) + } + }) + + err := runCmd("sensors", "add", testGatewayUUID, + testFlagType, "temperature", + "--min", "0", + "--max", "100", + testFlagAlgorithm, "constant", + ) if err == nil { t.Error(errExpected404) } @@ -473,18 +532,42 @@ func TestSensorsAddServerError(t *testing.T) { func TestSensorsListEmptyResult(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, []any{}) + switch r.URL.Path { + case pathGatewayUUID: + writeJSON(w, http.StatusOK, map[string]any{"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID}) + case pathGatewaySensors: + writeJSON(w, http.StatusOK, []any{}) + default: + t.Errorf(fmtUnexpectedPath, r.URL.Path) + } }) - if err := runCmd("sensors", "list", "5"); err != nil { + if err := runCmd("sensors", "list", testGatewayUUID); err != nil { t.Fatalf("sensors list empty failed: %v", err) } } func TestSensorsListServerError(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case pathGatewayUUID: + writeJSON(w, http.StatusOK, map[string]any{"id": testGatewayPublicID, "managementGatewayId": testGatewayUUID}) + case pathGatewaySensors: + http.Error(w, bodyNotFound, http.StatusNotFound) + default: + t.Errorf(fmtUnexpectedPath, r.URL.Path) + } + }) + if err := runCmd("sensors", "list", testGatewayUUID); err == nil { + t.Error(errExpected404) + } +} + +func TestSensorsListGatewayLookupError(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 { + + if err := runCmd("sensors", "list", testGatewayUUID); err == nil { t.Error(errExpected404) } } @@ -493,7 +576,7 @@ func TestSensorsDeleteServerError(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 { + if err := runCmd("sensors", "delete", testSensorUUID); err == nil { t.Error(errExpected404) } } @@ -520,7 +603,7 @@ func TestAnomaliesOutlierServerError(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 { + if err := runCmd("anomalies", "outlier", testSensorUUID); err == nil { t.Error(errExpected404) } } @@ -703,3 +786,42 @@ func TestPrintSensorTableEmptySliceNoOutput(t *testing.T) { t.Fatalf("expected no output for empty sensor table, got %q", out) } } + +func TestPrintGatewayTableEmptySliceNoOutput(t *testing.T) { + out := captureStdout(t, func() { + printGatewayTable([]client.Gateway{}) + }) + + if out != "" { + t.Fatalf("expected no output for empty gateway table, got %q", out) + } +} + +func TestGatewayUUIDResolution(t *testing.T) { + if got := gatewayUUID(client.Gateway{ID: "gw-1", ManagementGatewayID: testGatewayUUID}); got != testGatewayUUID { + t.Fatalf("gatewayUUID with management id = %q, want %q", got, testGatewayUUID) + } + if got := gatewayUUID(client.Gateway{ID: "gw-2"}); got != "gw-2" { + t.Fatalf("gatewayUUID fallback = %q, want %q", got, "gw-2") + } +} + +func TestAnomaliesOutlierNoValueIntegration(t *testing.T) { + newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sim/sensors/sensor-100/anomaly/outlier" { + t.Errorf(fmtUnexpectedPath, r.URL.Path) + } + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["value"]; ok { + t.Errorf("value should be omitted when --value is not provided") + } + + w.WriteHeader(http.StatusNoContent) + }) + + if err := runCmd("anomalies", "outlier", "sensor-100"); err != nil { + t.Fatalf("anomalies outlier without value failed: %v", err) + } +} diff --git a/cmd/gateways.go b/cmd/gateways.go index feb483e..4d0b263 100644 --- a/cmd/gateways.go +++ b/cmd/gateways.go @@ -41,15 +41,14 @@ var gatewaysListCmd = &cobra.Command{ } tableData := pterm.TableData{ - {"ID", "UUID", "Status", "Model", "Serial", "Freq (ms)", "Tenant"}, + {"ID", "UUID", "Status", "Model", "Freq (ms)", "Tenant"}, } for _, gw := range gateways { tableData = append(tableData, []string{ - strconv.FormatInt(gw.ID, 10), - gw.ManagementGatewayID, + gw.ID, + gatewayUUID(gw), statusStyle(gw.Status), gw.Model, - gw.SerialNumber, strconv.Itoa(gw.SendFrequencyMs), gw.TenantID, }) @@ -77,10 +76,9 @@ var gatewaysGetCmd = &cobra.Command{ return pterm.DefaultTable.WithData(pterm.TableData{ {"Field", "Value"}, - {"ID", strconv.FormatInt(gw.ID, 10)}, - {"UUID", gw.ManagementGatewayID}, + {"ID", gw.ID}, + {"UUID", gatewayUUID(*gw)}, {"Factory ID", gw.FactoryID}, - {"Serial", gw.SerialNumber}, {"Model", gw.Model}, {"Firmware", gw.FirmwareVersion}, {"Status", statusStyle(gw.Status)}, @@ -101,7 +99,6 @@ var gatewaysCreateCmd = &cobra.Command{ req := client.CreateGatewayRequest{} req.FactoryID, _ = cmd.Flags().GetString(flagFactoryID) req.FactoryKey, _ = cmd.Flags().GetString(flagFactoryKey) - req.SerialNumber, _ = cmd.Flags().GetString("serial") req.Model, _ = cmd.Flags().GetString("model") req.FirmwareVersion, _ = cmd.Flags().GetString("firmware") req.SendFrequencyMs, _ = cmd.Flags().GetInt("freq") @@ -235,20 +232,26 @@ func printGatewayTable(gateways []client.Gateway) { if len(gateways) == 0 { return } - tableData := pterm.TableData{{"ID", "UUID", "Status", "Model", "Serial", "Freq (ms)"}} + tableData := pterm.TableData{{"ID", "UUID", "Status", "Model", "Freq (ms)"}} for _, gw := range gateways { tableData = append(tableData, []string{ - strconv.FormatInt(gw.ID, 10), - gw.ManagementGatewayID, + gw.ID, + gatewayUUID(gw), statusStyle(gw.Status), gw.Model, - gw.SerialNumber, strconv.Itoa(gw.SendFrequencyMs), }) } pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck } +func gatewayUUID(gw client.Gateway) string { + if gw.ManagementGatewayID != "" { + return gw.ManagementGatewayID + } + return gw.ID +} + // ── init ────────────────────────────────────────────────────────────────────── func init() { @@ -266,11 +269,10 @@ func init() { // create flags gatewaysCreateCmd.Flags().String(flagFactoryID, "", "Factory ID (required)") gatewaysCreateCmd.Flags().String(flagFactoryKey, "", "Factory key (required)") - gatewaysCreateCmd.Flags().String("serial", "", "Serial number (required)") - gatewaysCreateCmd.Flags().String("model", "", "Gateway model") - gatewaysCreateCmd.Flags().String("firmware", "", "Firmware version") - gatewaysCreateCmd.Flags().Int("freq", 1000, "Send frequency in milliseconds") - for _, f := range []string{flagFactoryID, flagFactoryKey, "serial"} { + gatewaysCreateCmd.Flags().String("model", "", "Gateway model (required)") + gatewaysCreateCmd.Flags().String("firmware", "", "Firmware version (required)") + gatewaysCreateCmd.Flags().Int("freq", 1000, "Send frequency in milliseconds (required)") + for _, f := range []string{flagFactoryID, flagFactoryKey, "model", "firmware", "freq"} { mustMarkRequired(gatewaysCreateCmd, f) } @@ -278,10 +280,10 @@ func init() { gatewaysBulkCmd.Flags().Int("count", 1, "Number of gateways to create (required)") gatewaysBulkCmd.Flags().String(flagFactoryID, "", "Factory ID (required)") gatewaysBulkCmd.Flags().String(flagFactoryKey, "", "Factory key (required)") - gatewaysBulkCmd.Flags().String("model", "", "Gateway model") - gatewaysBulkCmd.Flags().String("firmware", "", "Firmware version") - gatewaysBulkCmd.Flags().Int("freq", 1000, "Send frequency in milliseconds") - for _, f := range []string{"count", flagFactoryID, flagFactoryKey} { + gatewaysBulkCmd.Flags().String("model", "", "Gateway model (required)") + gatewaysBulkCmd.Flags().String("firmware", "", "Firmware version (required)") + gatewaysBulkCmd.Flags().Int("freq", 1000, "Send frequency in milliseconds (required)") + for _, f := range []string{"count", flagFactoryID, flagFactoryKey, "model", "firmware", "freq"} { mustMarkRequired(gatewaysBulkCmd, f) } } diff --git a/cmd/request_mapping_test.go b/cmd/request_mapping_test.go index e36039e..f198bd2 100644 --- a/cmd/request_mapping_test.go +++ b/cmd/request_mapping_test.go @@ -13,7 +13,6 @@ import ( const ( fmtUnexpectedError = "unexpected error: %v" - flagSerialArg = "--serial" ) // readBody parses the request body into a generic map for field inspection. @@ -62,59 +61,53 @@ func TestGatewaysCreateFlagToJSONMapping(t *testing.T) { body := readBody(t, r) checkKey(t, body, "factoryId", "fac-42") checkKey(t, body, "factoryKey", "secret-key") - checkKey(t, body, "serialNumber", "SN-XYZ") checkKey(t, body, "model", "GW-PRO") checkKey(t, body, "firmwareVersion", "3.0.1") checkKey(t, body, "sendFrequencyMs", float64(250)) - writeJSON(w, http.StatusCreated, map[string]any{"id": 1}) + writeJSON(w, http.StatusCreated, map[string]any{"id": "gw-1"}) }) err := runCmd("gateways", "create", testFlagFactoryID, "fac-42", testFlagFactoryKey, "secret-key", - flagSerialArg, "SN-XYZ", - "--model", "GW-PRO", - "--firmware", "3.0.1", - "--freq", "250", + testFlagModel, "GW-PRO", + testFlagFirmware, "3.0.1", + testFlagFreq, "250", ) if err != nil { t.Fatalf("gateways create failed: %v", err) } } -func TestGatewaysCreateDefaultFreqIs1000(t *testing.T) { +func TestGatewaysCreateFreqMapping(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { body := readBody(t, r) checkKey(t, body, "sendFrequencyMs", float64(1000)) - writeJSON(w, http.StatusCreated, map[string]any{"id": 1}) + writeJSON(w, http.StatusCreated, map[string]any{"id": "gw-1"}) }) err := runCmd("gateways", "create", testFlagFactoryID, "f", testFlagFactoryKey, "k", - flagSerialArg, "SN", + testFlagModel, "GW-X", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", ) if err != nil { t.Fatalf(fmtUnexpectedError, err) } } -func TestGatewaysCreateOptionalFieldsOmittedWhenNotProvided(t *testing.T) { - newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - body := readBody(t, r) - checkAbsent(t, body, "model") - checkAbsent(t, body, "firmwareVersion") - writeJSON(w, http.StatusCreated, map[string]any{"id": 1}) - }) - +func TestGatewaysCreateMissingModelFails(t *testing.T) { err := runCmd("gateways", "create", testFlagFactoryID, "f", testFlagFactoryKey, "k", - flagSerialArg, "SN", + testFlagFirmware, "1.0.0", + testFlagFreq, "1000", ) - if err != nil { - t.Fatalf(fmtUnexpectedError, err) + if err == nil { + t.Fatal("expected error when --model is missing") } } @@ -143,9 +136,9 @@ func TestGatewaysBulkFlagToJSONMapping(t *testing.T) { "--count", "5", testFlagFactoryID, "fac-bulk", testFlagFactoryKey, "key-bulk", - "--model", "GW-MINI", - "--firmware", "1.2.3", - "--freq", "500", + testFlagModel, "GW-MINI", + testFlagFirmware, "1.2.3", + testFlagFreq, "500", ) if err != nil { t.Fatalf("gateways bulk failed: %v", err) @@ -160,11 +153,10 @@ func TestGatewaysGetIntegration(t *testing.T) { t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) } writeJSON(w, http.StatusOK, map[string]any{ - "id": 42, + "id": "gw-42", "managementGatewayId": "uuid-get-1", "status": "online", "model": "GW-X", - "serialNumber": "SN001", "firmwareVersion": "1.0", "sendFrequencyMs": 1000, "tenantId": "t-1", @@ -191,25 +183,35 @@ func TestGatewaysGetNotFound(t *testing.T) { func TestSensorsAddFlagToJSONMapping(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/sim/gateways/5/sensors" { - t.Errorf(fmtUnexpectedRequest, r.Method, r.URL.Path) + switch r.URL.Path { + case "/sim/gateways/uuid-gw-1": + writeJSON(w, http.StatusOK, map[string]any{ + "id": "gw-public-5", + "managementGatewayId": "uuid-gw-1", + }) + case "/sim/gateways/gw-public-5/sensors": + if r.Method != http.MethodPost { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + body := readBody(t, r) + + checkKey(t, body, "type", "humidity") + checkKey(t, body, "minRange", float64(10)) + checkAbsent(t, body, "min") + checkKey(t, body, "maxRange", float64(90)) + checkAbsent(t, body, "max") + checkKey(t, body, "algorithm", "uniform_random") + + writeJSON(w, http.StatusCreated, map[string]any{ + "id": "s-uuid", "gatewayId": "gw-public-5", + "type": "humidity", "minRange": 10, "maxRange": 90, "algorithm": "uniform_random", + }) + default: + t.Errorf(fmtUnexpectedPath, r.URL.Path) } - body := readBody(t, r) - - checkKey(t, body, "type", "humidity") - checkKey(t, body, "minRange", float64(10)) - checkAbsent(t, body, "min") - checkKey(t, body, "maxRange", float64(90)) - checkAbsent(t, body, "max") - checkKey(t, body, "algorithm", "uniform_random") - - writeJSON(w, http.StatusCreated, map[string]any{ - "id": 1, "gatewayId": 5, "sensorId": "s-uuid", - "type": "humidity", "minRange": 10, "maxRange": 90, "algorithm": "uniform_random", - }) }) - err := runCmd("sensors", "add", "5", + err := runCmd("sensors", "add", "uuid-gw-1", "--type", "humidity", "--min", "10", "--max", "90", @@ -222,13 +224,23 @@ func TestSensorsAddFlagToJSONMapping(t *testing.T) { func TestSensorsAddNegativeMinRange(t *testing.T) { newMockServer(t, func(w http.ResponseWriter, r *http.Request) { - body := readBody(t, r) - checkKey(t, body, "minRange", float64(-40)) - checkKey(t, body, "maxRange", float64(85)) - writeJSON(w, http.StatusCreated, map[string]any{"id": 2, "gatewayId": 3, "sensorId": "s-2"}) + switch r.URL.Path { + case "/sim/gateways/uuid-gw-2": + writeJSON(w, http.StatusOK, map[string]any{ + "id": "gw-public-3", + "managementGatewayId": "uuid-gw-2", + }) + case "/sim/gateways/gw-public-3/sensors": + body := readBody(t, r) + checkKey(t, body, "minRange", float64(-40)) + checkKey(t, body, "maxRange", float64(85)) + writeJSON(w, http.StatusCreated, map[string]any{"id": "s-2", "gatewayId": "gw-public-3"}) + default: + t.Errorf(fmtUnexpectedPath, r.URL.Path) + } }) - err := runCmd("sensors", "add", "3", + err := runCmd("sensors", "add", "uuid-gw-2", "--type", "temperature", "--min", "-40", "--max", "85", diff --git a/cmd/sensors.go b/cmd/sensors.go index e373181..9311519 100644 --- a/cmd/sensors.go +++ b/cmd/sensors.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "strconv" "github.com/NoTIPswe/notip-simulator-cli/internal/client" "github.com/pterm/pterm" @@ -14,14 +13,10 @@ var sensorsCmd = &cobra.Command{ Short: "Manage sensors attached to gateways", } -func resolveGatewayID(c *client.Client, input string) (int64, error) { - if gatewayID, err := strconv.ParseInt(input, 10, 64); err == nil { - return gatewayID, nil - } - +func resolveGatewayID(c *client.Client, input string) (string, error) { gw, err := c.GetGateway(input) if err != nil { - return 0, fmt.Errorf("gateway must be a numeric ID or a valid gateway UUID: %w", err) + return "", fmt.Errorf("gateway must be a valid gateway UUID: %w", err) } return gw.ID, nil } @@ -29,8 +24,8 @@ func resolveGatewayID(c *client.Client, input string) (int64, error) { // ── add ─────────────────────────────────────────────────────────────────────── var sensorsAddCmd = &cobra.Command{ - Use: "add ", - Short: "Add a sensor to a gateway (accepts numeric ID or UUID)", + Use: "add ", + Short: "Add a sensor to a gateway", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c := client.New(simulatorURL).WithContext(cmd.Context()) @@ -46,7 +41,7 @@ var sensorsAddCmd = &cobra.Command{ req.Algorithm, _ = cmd.Flags().GetString("algorithm") spinner := startSpinner( - fmt.Sprintf("Adding %s sensor to gateway %d...", req.Type, gatewayID), + fmt.Sprintf("Adding %s sensor to gateway %s...", req.Type, gatewayID), ) sensor, err := c.AddSensor(gatewayID, req) if err != nil { @@ -62,8 +57,8 @@ var sensorsAddCmd = &cobra.Command{ // ── list ────────────────────────────────────────────────────────────────────── var sensorsListCmd = &cobra.Command{ - Use: "list ", - Short: "List sensors for a gateway (accepts numeric ID or UUID)", + Use: "list ", + Short: "List sensors for a gateway", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c := client.New(simulatorURL).WithContext(cmd.Context()) @@ -73,7 +68,7 @@ var sensorsListCmd = &cobra.Command{ } spinner := startSpinner( - fmt.Sprintf("Fetching sensors for gateway %d...", gatewayID), + fmt.Sprintf("Fetching sensors for gateway %s...", gatewayID), ) sensors, err := c.ListSensors(gatewayID) if err != nil { @@ -94,21 +89,18 @@ var sensorsListCmd = &cobra.Command{ // ── delete ──────────────────────────────────────────────────────────────────── var sensorsDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a sensor by its numeric ID", + Use: "delete ", + Short: "Delete a sensor by UUID", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - sensorID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("sensor-id must be a numeric ID: %w", err) - } + sensorID := args[0] - spinner := startSpinner(fmt.Sprintf("Deleting sensor %d...", sensorID)) + spinner := startSpinner(fmt.Sprintf("Deleting sensor %s...", sensorID)) if err := client.New(simulatorURL).WithContext(cmd.Context()).DeleteSensor(sensorID); err != nil { spinner.Fail("Failed to delete sensor") return err } - spinner.Success(fmt.Sprintf("Sensor %d deleted", sensorID)) + spinner.Success(fmt.Sprintf("Sensor %s deleted", sensorID)) return nil }, } @@ -122,8 +114,8 @@ func printSensorTable(sensors []client.Sensor) { tableData := pterm.TableData{{"ID", "UUID", "Type", "Min", "Max", "Algorithm"}} for _, s := range sensors { tableData = append(tableData, []string{ - strconv.FormatInt(s.ID, 10), - s.SensorID, + s.ID, + s.ID, s.Type, fmt.Sprintf("%.2f", s.MinRange), fmt.Sprintf("%.2f", s.MaxRange), diff --git a/cmd/shell.go b/cmd/shell.go index 091d430..ffe776a 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -2,6 +2,7 @@ package cmd import ( "bufio" + "errors" "fmt" "io" "os" @@ -10,6 +11,30 @@ import ( "github.com/pterm/pterm" "github.com/pterm/pterm/putils" "github.com/spf13/cobra" + "golang.org/x/term" +) + +type shellLineEditor interface { + ReadLine() (string, error) +} + +const goodbyeMessage = "Goodbye!" + +var ( + shellStdin = func() *os.File { return os.Stdin } + shellStdout = func() *os.File { return os.Stdout } + shellIsTerminal = term.IsTerminal + shellMakeRaw = term.MakeRaw + shellRestore = term.Restore + shellNewEditor = func(rw io.ReadWriter, prompt string) shellLineEditor { + return term.NewTerminal(rw, prompt) + } + renderWelcomeBigText = func() error { + return pterm.DefaultBigText.WithLetters( + putils.LettersFromStringWithStyle("sim", pterm.NewStyle(pterm.FgGreen)), + putils.LettersFromStringWithStyle("-cli", pterm.NewStyle(pterm.FgGray)), + ).Render() + } ) var shellCmd = &cobra.Command{ @@ -25,7 +50,15 @@ restarting the container. Type 'help' for available commands, 'exit' to quit.`, rootCmd.SilenceUsage = true defer func() { rootCmd.SilenceUsage = false }() - reader := bufio.NewReader(os.Stdin) + if canUseLineEditor() { + if err := runShellWithLineEditor(); err != nil { + pterm.Warning.Printf("line editor disabled: %v\n", err) + } else { + return nil + } + } + + reader := bufio.NewReader(shellStdin()) for { printPrompt() @@ -33,34 +66,92 @@ restarting the container. Type 'help' for available commands, 'exit' to quit.`, if err != nil { if err == io.EOF { fmt.Println() - pterm.Info.Println("Goodbye!") + pterm.Info.Println(goodbyeMessage) } return nil } - line = strings.TrimSpace(line) - if line == "" { - continue - } - if line == "exit" || line == "quit" { - pterm.Info.Println("Goodbye!") + if processShellLine(line) { return nil } + } + }, +} - // Prevent the user from nesting shells. - parts := strings.Fields(line) - if parts[0] == "shell" { - pterm.Warning.Println("Already inside a shell session.") - continue - } +func canUseLineEditor() bool { + return shellIsTerminal(int(shellStdin().Fd())) && shellIsTerminal(int(shellStdout().Fd())) +} - resetAllCommandFlags(rootCmd) - rootCmd.SetArgs(parts) - if execErr := rootCmd.Execute(); execErr != nil { - pterm.Error.Println(execErr) +func runShellWithLineEditor() error { + stdin := shellStdin() + stdout := shellStdout() + + fd := int(stdin.Fd()) + oldState, err := shellMakeRaw(fd) + if err != nil { + return err + } + defer func() { + _ = shellRestore(fd, oldState) + }() + + lineEditor := shellNewEditor(struct { + io.Reader + io.Writer + }{ + Reader: stdin, + Writer: stdout, + }, "sim-cli> ") + + for { + line, readErr := lineEditor.ReadLine() + if readErr != nil { + if errors.Is(readErr, io.EOF) { + fmt.Println() + pterm.Info.Println(goodbyeMessage) + return nil } + return readErr } - }, + + // Leave raw mode while the command runs so output renders correctly. + if err := shellRestore(fd, oldState); err != nil { + return err + } + shouldExit := processShellLine(line) + if _, err := shellMakeRaw(fd); err != nil { + return err + } + if shouldExit { + return nil + } + } +} + +func processShellLine(line string) bool { + line = strings.TrimSpace(line) + if line == "" { + return false + } + if line == "exit" || line == "quit" { + pterm.Info.Println(goodbyeMessage) + return true + } + + // Prevent the user from nesting shells. + args := strings.Fields(line) + if args[0] == "shell" { + pterm.Warning.Println("Already inside a shell session.") + return false + } + + resetAllCommandFlags(rootCmd) + rootCmd.SetArgs(args) + if err := rootCmd.Execute(); err != nil { + pterm.Error.Println(err) + } + + return false } func printPrompt() { @@ -73,10 +164,7 @@ func printPrompt() { func printWelcomeBanner() { if !pterm.RawOutput { - if err := pterm.DefaultBigText.WithLetters( - putils.LettersFromStringWithStyle("sim", pterm.NewStyle(pterm.FgGreen)), - putils.LettersFromStringWithStyle("-cli", pterm.NewStyle(pterm.FgGray)), - ).Render(); err != nil { + if err := renderWelcomeBigText(); err != nil { pterm.Warning.Printf("failed to render banner: %v\n", err) } } diff --git a/cmd/shell_test.go b/cmd/shell_test.go index 646cad4..1210710 100644 --- a/cmd/shell_test.go +++ b/cmd/shell_test.go @@ -1,12 +1,72 @@ package cmd import ( + "bytes" + "errors" + "io" + "os" "strings" "testing" "github.com/pterm/pterm" + "golang.org/x/term" ) +type shellReadEvent struct { + line string + err error +} + +type scriptedShellEditor struct { + events []shellReadEvent + index int +} + +func (s *scriptedShellEditor) ReadLine() (string, error) { + if s.index >= len(s.events) { + return "", io.EOF + } + event := s.events[s.index] + s.index++ + return event.line, event.err +} + +func setShellHooksForTest(t *testing.T) { + t.Helper() + + prevStdin := shellStdin + prevStdout := shellStdout + prevIsTerminal := shellIsTerminal + prevMakeRaw := shellMakeRaw + prevRestore := shellRestore + prevNewEditor := shellNewEditor + prevRenderBanner := renderWelcomeBigText + + t.Cleanup(func() { + shellStdin = prevStdin + shellStdout = prevStdout + shellIsTerminal = prevIsTerminal + shellMakeRaw = prevMakeRaw + shellRestore = prevRestore + shellNewEditor = prevNewEditor + renderWelcomeBigText = prevRenderBanner + }) +} + +func makePipePair(t *testing.T) (*os.File, *os.File) { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + _ = w.Close() + }) + return r, w +} + func TestPrintPromptRawOutput(t *testing.T) { prevRaw := pterm.RawOutput pterm.RawOutput = true @@ -52,3 +112,159 @@ func TestPrintWelcomeBannerNonRawOutput(t *testing.T) { _ = captureStdout(t, printWelcomeBanner) } + +func TestPrintWelcomeBannerRenderError(t *testing.T) { + setShellHooksForTest(t) + + prevRaw := pterm.RawOutput + pterm.RawOutput = false + t.Cleanup(func() { + pterm.RawOutput = prevRaw + }) + + renderWelcomeBigText = func() error { + return errors.New("render failed") + } + + _ = captureStdout(t, printWelcomeBanner) +} + +func TestRunShellWithLineEditorEOF(t *testing.T) { + setShellHooksForTest(t) + + inR, _ := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + shellMakeRaw = func(int) (*term.State, error) { return &term.State{}, nil } + shellRestore = func(int, *term.State) error { return nil } + shellNewEditor = func(io.ReadWriter, string) shellLineEditor { + return &scriptedShellEditor{events: []shellReadEvent{{err: io.EOF}}} + } + + if err := runShellWithLineEditor(); err != nil { + t.Fatalf("runShellWithLineEditor() error = %v, want nil", err) + } +} + +func TestRunShellWithLineEditorReadError(t *testing.T) { + setShellHooksForTest(t) + + inR, _ := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + shellMakeRaw = func(int) (*term.State, error) { return &term.State{}, nil } + shellRestore = func(int, *term.State) error { return nil } + shellNewEditor = func(io.ReadWriter, string) shellLineEditor { + return &scriptedShellEditor{events: []shellReadEvent{{err: errors.New("read failed")}}} + } + + if err := runShellWithLineEditor(); err == nil { + t.Fatal("expected read error, got nil") + } +} + +func TestRunShellWithLineEditorRestoreError(t *testing.T) { + setShellHooksForTest(t) + + inR, _ := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + shellMakeRaw = func(int) (*term.State, error) { return &term.State{}, nil } + restoreCalls := 0 + shellRestore = func(int, *term.State) error { + restoreCalls++ + if restoreCalls == 1 { + return errors.New("restore failed") + } + return nil + } + shellNewEditor = func(io.ReadWriter, string) shellLineEditor { + return &scriptedShellEditor{events: []shellReadEvent{{line: ""}}} + } + + if err := runShellWithLineEditor(); err == nil { + t.Fatal("expected restore error, got nil") + } +} + +func TestRunShellWithLineEditorMakeRawAfterCommandError(t *testing.T) { + setShellHooksForTest(t) + + inR, _ := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + makeRawCalls := 0 + shellMakeRaw = func(int) (*term.State, error) { + makeRawCalls++ + if makeRawCalls == 2 { + return nil, errors.New("make raw failed") + } + return &term.State{}, nil + } + shellRestore = func(int, *term.State) error { return nil } + shellNewEditor = func(io.ReadWriter, string) shellLineEditor { + return &scriptedShellEditor{events: []shellReadEvent{{line: ""}}} + } + + if err := runShellWithLineEditor(); err == nil { + t.Fatal("expected make-raw error, got nil") + } +} + +func TestShellCommandUsesLineEditor(t *testing.T) { + setShellHooksForTest(t) + + inR, _ := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + shellIsTerminal = func(int) bool { return true } + shellMakeRaw = func(int) (*term.State, error) { return &term.State{}, nil } + shellRestore = func(int, *term.State) error { return nil } + shellNewEditor = func(io.ReadWriter, string) shellLineEditor { + return &scriptedShellEditor{events: []shellReadEvent{{line: "exit"}}} + } + + if err := runCmd("shell"); err != nil { + t.Fatalf("shell command failed with line editor: %v", err) + } +} + +func TestShellCommandFallsBackWhenLineEditorFails(t *testing.T) { + setShellHooksForTest(t) + + inR, inW := makePipePair(t) + _, outW := makePipePair(t) + shellStdin = func() *os.File { return inR } + shellStdout = func() *os.File { return outW } + shellIsTerminal = func(int) bool { return true } + shellMakeRaw = func(int) (*term.State, error) { return nil, errors.New("raw mode unavailable") } + + if _, err := inW.WriteString("exit\n"); err != nil { + t.Fatalf("write fallback input: %v", err) + } + if err := inW.Close(); err != nil { + t.Fatalf("close fallback input writer: %v", err) + } + + if err := runCmd("shell"); err != nil { + t.Fatalf("shell command failed during fallback path: %v", err) + } +} + +func TestShellDefaultHooksCoverage(t *testing.T) { + setShellHooksForTest(t) + + if shellStdout() == nil { + t.Fatal("shellStdout should return a file") + } + + var rw bytes.Buffer + if shellNewEditor(&rw, "sim-cli> ") == nil { + t.Fatal("shellNewEditor should return a line editor") + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 1c71949..94d5c87 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ) @@ -23,13 +22,11 @@ const ( // ── Domain types ───────────────────────────────────────────────────────────── // Gateway mirrors the GatewayResponse DTO returned by the backend. -// The numeric ID is used for sensor operations; the UUID (ManagementGatewayID) -// is used for gateway lifecycle and anomaly operations. +// ID is the public gateway identifier (UUID in current API). type Gateway struct { - ID int64 `json:"id"` - ManagementGatewayID string `json:"managementGatewayId"` + ID string `json:"id"` + ManagementGatewayID string `json:"managementGatewayId,omitempty"` FactoryID string `json:"factoryId"` - SerialNumber string `json:"serialNumber"` Model string `json:"model"` FirmwareVersion string `json:"firmwareVersion"` Provisioned bool `json:"provisioned"` @@ -41,9 +38,8 @@ type Gateway struct { // Sensor mirrors the SensorResponse DTO returned by the backend. type Sensor struct { - ID int64 `json:"id"` - GatewayID int64 `json:"gatewayId"` - SensorID string `json:"sensorId"` + ID string `json:"id"` + GatewayID string `json:"gatewayId"` Type string `json:"type"` MinRange float64 `json:"minRange"` MaxRange float64 `json:"maxRange"` @@ -57,7 +53,6 @@ type Sensor struct { type CreateGatewayRequest struct { FactoryID string `json:"factoryId"` FactoryKey string `json:"factoryKey"` - SerialNumber string `json:"serialNumber"` Model string `json:"model,omitempty"` FirmwareVersion string `json:"firmwareVersion,omitempty"` SendFrequencyMs int `json:"sendFrequencyMs,omitempty"` @@ -81,7 +76,7 @@ type BulkCreateResponse struct { } // AddSensorRequest is the payload for POST /sim/gateways/{id}/sensors. -// The gateway ID in the path is the numeric int64 ID, not the UUID. +// The gateway ID in the path is the public gateway identifier (UUID string). type AddSensorRequest struct { Type string `json:"type"` MinRange float64 `json:"minRange"` @@ -242,9 +237,9 @@ func (c *Client) DeleteGateway(id string) error { // ── Sensor endpoints ────────────────────────────────────────────────────────── // AddSensor calls POST /sim/gateways/{id}/sensors. -// gatewayID is the numeric int64 ID (not the UUID). -func (c *Client) AddSensor(gatewayID int64, req AddSensorRequest) (*Sensor, error) { - path := pathGateways + strconv.FormatInt(gatewayID, 10) + "/sensors" +// gatewayID is the public gateway identifier (UUID in current API). +func (c *Client) AddSensor(gatewayID string, req AddSensorRequest) (*Sensor, error) { + path := pathGateways + gatewayID + "/sensors" resp, err := c.post(path, req) if err != nil { return nil, err @@ -258,9 +253,9 @@ func (c *Client) AddSensor(gatewayID int64, req AddSensorRequest) (*Sensor, erro } // ListSensors calls GET /sim/gateways/{id}/sensors. -// gatewayID is the numeric int64 ID. -func (c *Client) ListSensors(gatewayID int64) ([]Sensor, error) { - url := c.baseURL + pathGateways + strconv.FormatInt(gatewayID, 10) + "/sensors" +// gatewayID is the public gateway identifier (UUID in current API). +func (c *Client) ListSensors(gatewayID string) ([]Sensor, error) { + url := c.baseURL + pathGateways + gatewayID + "/sensors" req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf(errBuildRequest, err) @@ -278,9 +273,9 @@ func (c *Client) ListSensors(gatewayID int64) ([]Sensor, error) { } // DeleteSensor calls DELETE /sim/sensors/{sensorId}. -// sensorID is the numeric int64 ID. -func (c *Client) DeleteSensor(sensorID int64) error { - req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.baseURL+pathSensors+strconv.FormatInt(sensorID, 10), nil) +// sensorID is the public sensor identifier (UUID in current API). +func (c *Client) DeleteSensor(sensorID string) error { + req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.baseURL+pathSensors+sensorID, nil) if err != nil { return fmt.Errorf(errBuildRequest, err) } @@ -322,10 +317,11 @@ func (c *Client) Disconnect(gatewayID string, durationSeconds int) error { } // InjectOutlier calls POST /sim/sensors/{sensorId}/anomaly/outlier. -// sensorID is the numeric int64 ID. value is optional (pass nil to omit). -func (c *Client) InjectOutlier(sensorID int64, value *float64) error { +// sensorID is the public sensor identifier (UUID in current API). +// value is optional (pass nil to omit). +func (c *Client) InjectOutlier(sensorID string, value *float64) error { req := OutlierRequest{Value: value} - resp, err := c.post(pathSensors+strconv.FormatInt(sensorID, 10)+"/anomaly/outlier", req) + resp, err := c.post(pathSensors+sensorID+"/anomaly/outlier", req) if err != nil { return err } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index cdb363d..a708bd2 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -1,8 +1,10 @@ package client_test import ( + "context" "encoding/json" "io" + "math" "net/http" "net/http/httptest" "strings" @@ -63,23 +65,22 @@ func assertPath(t *testing.T, r *http.Request, want string) { // ── Gateway ─────────────────────────────────────────────────────────────────── func TestCreateGatewaySuccess(t *testing.T) { - want := client.Gateway{ID: 1, ManagementGatewayID: gwUUID1, Status: "online"} + want := client.Gateway{ID: "gw-1", ManagementGatewayID: gwUUID1, Status: "online"} _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodPost) assertPath(t, r, "/sim/gateways") var req client.CreateGatewayRequest decodeBody(t, r, &req) - if req.FactoryID != "fac-1" || req.SerialNumber != "SN-001" { + if req.FactoryID != "fac-1" { t.Errorf("unexpected request body: %+v", req) } writeJSON(w, http.StatusCreated, want) }) got, err := c.CreateGateway(client.CreateGatewayRequest{ - FactoryID: "fac-1", - FactoryKey: "key-1", - SerialNumber: "SN-001", + FactoryID: "fac-1", + FactoryKey: "key-1", }) if err != nil { t.Fatalf(errFmtUnexpected, err) @@ -101,7 +102,7 @@ func TestCreateGatewayServerError(t *testing.T) { func TestBulkCreateGatewaysAllSuccess(t *testing.T) { want := client.BulkCreateResponse{ - Gateways: []client.Gateway{{ID: 1}, {ID: 2}}, + Gateways: []client.Gateway{{ID: "gw-1"}, {ID: "gw-2"}}, Errors: []string{"", ""}, } _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -127,7 +128,7 @@ func TestBulkCreateGatewaysAllSuccess(t *testing.T) { func TestBulkCreateGatewaysPartialErrors207(t *testing.T) { want := client.BulkCreateResponse{ - Gateways: []client.Gateway{{ID: 1}}, + Gateways: []client.Gateway{{ID: "gw-1"}}, Errors: []string{"", "factory key mismatch"}, } _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -144,7 +145,7 @@ func TestBulkCreateGatewaysPartialErrors207(t *testing.T) { } func TestListGatewaysSuccess(t *testing.T) { - want := []client.Gateway{{ID: 1, Status: "online"}, {ID: 2, Status: "offline"}} + want := []client.Gateway{{ID: "gw-1", Status: "online"}, {ID: "gw-2", Status: "offline"}} _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodGet) assertPath(t, r, "/sim/gateways") @@ -177,7 +178,7 @@ func TestListGatewaysEmpty(t *testing.T) { } func TestGetGatewaySuccess(t *testing.T) { - want := client.Gateway{ID: 42, ManagementGatewayID: "uuid-42", Status: "online"} + want := client.Gateway{ID: "gw-42", ManagementGatewayID: "uuid-42", Status: "online"} _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodGet) assertPath(t, r, "/sim/gateways/uuid-42") @@ -188,8 +189,8 @@ func TestGetGatewaySuccess(t *testing.T) { if err != nil { t.Fatalf(errFmtUnexpected, err) } - if got.ID != 42 { - t.Errorf("got ID %d, want 42", got.ID) + if got.ID != "gw-42" { + t.Errorf("got ID %s, want gw-42", got.ID) } } @@ -257,10 +258,10 @@ func TestDeleteGatewayNotFound(t *testing.T) { // ── Sensor ──────────────────────────────────────────────────────────────────── func TestAddSensorSuccess(t *testing.T) { - want := client.Sensor{ID: 10, GatewayID: 5, Type: "temperature", MinRange: 0, MaxRange: 100, Algorithm: "sine_wave"} + want := client.Sensor{ID: "sensor-10", GatewayID: "gw-5", Type: "temperature", MinRange: 0, MaxRange: 100, Algorithm: "sine_wave"} _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodPost) - assertPath(t, r, "/sim/gateways/5/sensors") + assertPath(t, r, "/sim/gateways/gw-5/sensors") var req client.AddSensorRequest decodeBody(t, r, &req) @@ -270,7 +271,7 @@ func TestAddSensorSuccess(t *testing.T) { writeJSON(w, http.StatusCreated, want) }) - got, err := c.AddSensor(5, client.AddSensorRequest{ + got, err := c.AddSensor("gw-5", client.AddSensorRequest{ Type: "temperature", MinRange: 0, MaxRange: 100, @@ -279,7 +280,7 @@ func TestAddSensorSuccess(t *testing.T) { if err != nil { t.Fatalf(errFmtUnexpected, err) } - if got.ID != 10 || got.Type != "temperature" { + if got.ID != "sensor-10" || got.Type != "temperature" { t.Errorf("got %+v, want %+v", got, want) } } @@ -288,7 +289,7 @@ func TestAddSensorGatewayNotFound(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, errMsgNotFound, http.StatusNotFound) }) - _, err := c.AddSensor(999, client.AddSensorRequest{Type: "temperature", Algorithm: "constant"}) + _, err := c.AddSensor("ghost", client.AddSensorRequest{Type: "temperature", Algorithm: "constant"}) if err == nil { t.Fatal(errExpected404) } @@ -296,16 +297,16 @@ func TestAddSensorGatewayNotFound(t *testing.T) { func TestListSensorsSuccess(t *testing.T) { want := []client.Sensor{ - {ID: 1, Type: "temperature"}, - {ID: 2, Type: "humidity"}, + {ID: "sensor-1", Type: "temperature"}, + {ID: "sensor-2", Type: "humidity"}, } _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodGet) - assertPath(t, r, "/sim/gateways/7/sensors") + assertPath(t, r, "/sim/gateways/gw-7/sensors") writeJSON(w, http.StatusOK, want) }) - got, err := c.ListSensors(7) + got, err := c.ListSensors("gw-7") if err != nil { t.Fatalf(errFmtUnexpected, err) } @@ -317,10 +318,10 @@ func TestListSensorsSuccess(t *testing.T) { func TestDeleteSensorSuccess(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodDelete) - assertPath(t, r, "/sim/sensors/99") + assertPath(t, r, "/sim/sensors/sensor-99") w.WriteHeader(http.StatusNoContent) }) - if err := c.DeleteSensor(99); err != nil { + if err := c.DeleteSensor("sensor-99"); err != nil { t.Fatalf(errFmtUnexpected, err) } } @@ -329,7 +330,7 @@ func TestDeleteSensorNotFound(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, errMsgNotFound, http.StatusNotFound) }) - if err := c.DeleteSensor(0); err == nil { + if err := c.DeleteSensor("ghost"); err == nil { t.Fatal(errExpected404) } } @@ -402,7 +403,7 @@ func TestInjectOutlierWithValue(t *testing.T) { val := 999.9 _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assertMethod(t, r, http.MethodPost) - assertPath(t, r, "/sim/sensors/42/anomaly/outlier") + assertPath(t, r, "/sim/sensors/sensor-42/anomaly/outlier") body, _ := io.ReadAll(r.Body) var req map[string]any @@ -412,7 +413,7 @@ func TestInjectOutlierWithValue(t *testing.T) { } w.WriteHeader(http.StatusNoContent) }) - if err := c.InjectOutlier(42, &val); err != nil { + if err := c.InjectOutlier("sensor-42", &val); err != nil { t.Fatalf(errFmtUnexpected, err) } } @@ -427,7 +428,7 @@ func TestInjectOutlierNoValue(t *testing.T) { } w.WriteHeader(http.StatusNoContent) }) - if err := c.InjectOutlier(42, nil); err != nil { + if err := c.InjectOutlier("sensor-42", nil); err != nil { t.Fatalf(errFmtUnexpected, err) } } @@ -436,7 +437,7 @@ func TestInjectOutlierSensorNotFound(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, errMsgNotFound, http.StatusNotFound) }) - if err := c.InjectOutlier(0, nil); err == nil { + if err := c.InjectOutlier("ghost", nil); err == nil { t.Fatal(errExpected404) } } @@ -467,9 +468,8 @@ func TestCreateGatewayInvalidJSONResponse(t *testing.T) { }) _, err := c.CreateGateway(client.CreateGatewayRequest{ - FactoryID: "f-1", - FactoryKey: "k-1", - SerialNumber: "SN-1", + FactoryID: "f-1", + FactoryKey: "k-1", }) if err == nil { t.Fatal(errExpectedDecode) @@ -526,7 +526,7 @@ func TestAddSensorInvalidJSONResponse(t *testing.T) { _, _ = w.Write([]byte(invalidJSONPayload)) }) - _, err := c.AddSensor(1, client.AddSensorRequest{Type: "temperature", Algorithm: "constant"}) + _, err := c.AddSensor("gw-1", client.AddSensorRequest{Type: "temperature", Algorithm: "constant"}) if err == nil { t.Fatal(errExpectedDecode) } @@ -539,8 +539,64 @@ func TestListSensorsInvalidJSONResponse(t *testing.T) { _, _ = w.Write([]byte(invalidJSONPayload)) }) - _, err := c.ListSensors(1) + _, err := c.ListSensors("gw-1") if err == nil { t.Fatal(errExpectedDecode) } } + +func TestWithContextNilFallback(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []client.Gateway{}) + }) + + cc := c.WithContext(nil) //nolint:staticcheck // Intentional nil input to validate WithContext fallback behavior. + if cc == c { + t.Fatal("WithContext should return a shallow copy") + } + + if _, err := cc.ListGateways(); err != nil { + t.Fatalf(errFmtUnexpected, err) + } +} + +func TestWithContextUsesProvidedContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("request should not be sent when context is already canceled") + }) + + err := c.WithContext(ctx).StartGateway("gw-1") + if err == nil { + t.Fatal("expected context cancellation error, got nil") + } +} + +func TestAddSensorPayloadEncodeError(t *testing.T) { + c := client.New("http://example.invalid") + _, err := c.AddSensor("gw-1", client.AddSensorRequest{ + Type: "temperature", + MinRange: math.NaN(), + MaxRange: 100, + Algorithm: "constant", + }) + if err == nil { + t.Fatal("expected payload encode error, got nil") + } + if !strings.Contains(err.Error(), "failed to encode payload") { + t.Fatalf(errFmtUnexpected, err) + } +} + +func TestListGatewaysInvalidBaseURL(t *testing.T) { + c := client.New("://bad-url") + _, err := c.ListGateways() + if err == nil { + t.Fatal("expected build request error, got nil") + } + if !strings.Contains(err.Error(), "failed to build request") { + t.Fatalf(errFmtUnexpected, err) + } +} diff --git a/internal/client/request_construction_test.go b/internal/client/request_construction_test.go index bb1c69f..4603246 100644 --- a/internal/client/request_construction_test.go +++ b/internal/client/request_construction_test.go @@ -1,7 +1,7 @@ // request_construction_test.go verifies that every client method builds the HTTP // request exactly as the simulator backend expects it: // - correct HTTP method -// - correct URL path (including /sim/ prefix and numeric vs UUID IDs) +// - correct URL path (including /sim/ prefix and UUID/public string IDs) // - correct Content-Type header on POST-with-body requests // - exact JSON field names in the request body // - omitempty: optional fields are absent when zero-valued @@ -17,6 +17,15 @@ import ( "github.com/NoTIPswe/notip-simulator-cli/internal/client" ) +const ( + reqHeaderContentType = "Content-Type" + reqMediaTypeJSON = "application/json" + fmtMethodWantPost = "method = %s, want POST" + fmtPathOnly = "path = %s" + errUnexpected = "unexpected error: %v" + testGatewayUUIDX = "uuid-x" +) + // ── helpers ─────────────────────────────────────────────────────────────────── func readBodyAsMap(t *testing.T, r *http.Request) map[string]any { @@ -37,9 +46,9 @@ func readBodyAsMap(t *testing.T, r *http.Request) map[string]any { func assertContentType(t *testing.T, r *http.Request) { t.Helper() - ct := r.Header.Get("Content-Type") - if ct != "application/json" { - t.Errorf("Content-Type = %q, want %q", ct, "application/json") + ct := r.Header.Get(reqHeaderContentType) + if ct != reqMediaTypeJSON { + t.Errorf("Content-Type = %q, want %q", ct, reqMediaTypeJSON) } } @@ -72,10 +81,10 @@ func assertKeyAbsent(t *testing.T, m map[string]any, key string) { // ── POST /sim/gateways — single create ─────────────────────────────────────── -func TestCreateGateway_RequestConstruction(t *testing.T) { +func TestCreateGatewayRequestConstruction(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - t.Errorf("method = %s, want POST", r.Method) + t.Errorf(fmtMethodWantPost, r.Method) } if r.URL.Path != "/sim/gateways" { t.Errorf("path = %s, want /sim/gateways", r.URL.Path) @@ -85,50 +94,47 @@ func TestCreateGateway_RequestConstruction(t *testing.T) { body := readBodyAsMap(t, r) assertKey(t, body, "factoryId", "fac-1") assertKey(t, body, "factoryKey", "key-secret") - assertKey(t, body, "serialNumber", "SN-001") assertKey(t, body, "model", "GW-X200") assertKey(t, body, "firmwareVersion", "2.1.0") assertKey(t, body, "sendFrequencyMs", float64(500)) - writeJSON(w, http.StatusCreated, client.Gateway{ID: 1}) + writeJSON(w, http.StatusCreated, client.Gateway{ID: "gw-1"}) }) _, err := c.CreateGateway(client.CreateGatewayRequest{ FactoryID: "fac-1", FactoryKey: "key-secret", - SerialNumber: "SN-001", Model: "GW-X200", FirmwareVersion: "2.1.0", SendFrequencyMs: 500, }) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } } -func TestCreateGateway_OptionalFields_OmittedWhenZero(t *testing.T) { +func TestCreateGatewayOptionalFieldsOmittedWhenZero(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { body := readBodyAsMap(t, r) // model, firmwareVersion, sendFrequencyMs are omitempty — absent when zero assertKeyAbsent(t, body, "model") assertKeyAbsent(t, body, "firmwareVersion") assertKeyAbsent(t, body, "sendFrequencyMs") - writeJSON(w, http.StatusCreated, client.Gateway{ID: 1}) + writeJSON(w, http.StatusCreated, client.Gateway{ID: "gw-1"}) }) _, _ = c.CreateGateway(client.CreateGatewayRequest{ - FactoryID: "f", - FactoryKey: "k", - SerialNumber: "SN", + FactoryID: "f", + FactoryKey: "k", }) } // ── POST /sim/gateways/bulk ─────────────────────────────────────────────────── -func TestBulkCreateGateways_RequestConstruction(t *testing.T) { +func TestBulkCreateGatewaysRequestConstruction(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - t.Errorf("method = %s, want POST", r.Method) + t.Errorf(fmtMethodWantPost, r.Method) } if r.URL.Path != "/sim/gateways/bulk" { t.Errorf("path = %s, want /sim/gateways/bulk", r.URL.Path) @@ -144,7 +150,7 @@ func TestBulkCreateGateways_RequestConstruction(t *testing.T) { assertKey(t, body, "sendFrequencyMs", float64(2000)) writeJSON(w, http.StatusCreated, client.BulkCreateResponse{ - Gateways: []client.Gateway{{ID: 1}, {ID: 2}, {ID: 3}}, + Gateways: []client.Gateway{{ID: "gw-1"}, {ID: "gw-2"}, {ID: "gw-3"}}, Errors: []string{"", "", ""}, }) }) @@ -158,11 +164,11 @@ func TestBulkCreateGateways_RequestConstruction(t *testing.T) { SendFrequencyMs: 2000, }) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } } -func TestBulkCreateGateways_OptionalFields_OmittedWhenZero(t *testing.T) { +func TestBulkCreateGatewaysOptionalFieldsOmittedWhenZero(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { body := readBodyAsMap(t, r) assertKeyAbsent(t, body, "model") @@ -175,10 +181,10 @@ func TestBulkCreateGateways_OptionalFields_OmittedWhenZero(t *testing.T) { // ── GET /sim/gateways — no body ─────────────────────────────────────────────── -func TestListGateways_NoBodyNoContentType(t *testing.T) { +func TestListGatewaysNoBodyNoContentType(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-Type") != "" { - t.Errorf("GET should not set Content-Type, got %q", r.Header.Get("Content-Type")) + if r.Header.Get(reqHeaderContentType) != "" { + t.Errorf("GET should not set Content-Type, got %q", r.Header.Get(reqHeaderContentType)) } assertNoBody(t, r) writeJSON(w, http.StatusOK, []client.Gateway{}) @@ -188,13 +194,13 @@ func TestListGateways_NoBodyNoContentType(t *testing.T) { // ── POST /sim/gateways/{id}/start & stop — no body ─────────────────────────── -func TestStartGateway_NoBody(t *testing.T) { +func TestStartGatewayNoBody(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-abc/start" { - t.Errorf("path = %s", r.URL.Path) + t.Errorf(fmtPathOnly, r.URL.Path) } // POST with no body: Content-Type should not be set - if r.Header.Get("Content-Type") != "" { + if r.Header.Get(reqHeaderContentType) != "" { t.Errorf("POST-no-body should not set Content-Type") } assertNoBody(t, r) @@ -203,12 +209,12 @@ func TestStartGateway_NoBody(t *testing.T) { _ = c.StartGateway("uuid-abc") } -func TestStopGateway_NoBody(t *testing.T) { +func TestStopGatewayNoBody(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sim/gateways/uuid-abc/stop" { - t.Errorf("path = %s", r.URL.Path) + t.Errorf(fmtPathOnly, r.URL.Path) } - if r.Header.Get("Content-Type") != "" { + if r.Header.Get(reqHeaderContentType) != "" { t.Errorf("POST-no-body should not set Content-Type") } assertNoBody(t, r) @@ -219,13 +225,13 @@ func TestStopGateway_NoBody(t *testing.T) { // ── DELETE /sim/gateways/{id} — no body ────────────────────────────────────── -func TestDeleteGateway_MethodAndNoBody(t *testing.T) { +func TestDeleteGatewayMethodAndNoBody(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("method = %s, want DELETE", r.Method) } if r.URL.Path != "/sim/gateways/uuid-del" { - t.Errorf("path = %s", r.URL.Path) + t.Errorf(fmtPathOnly, r.URL.Path) } assertNoBody(t, r) w.WriteHeader(http.StatusNoContent) @@ -238,13 +244,13 @@ func TestDeleteGateway_MethodAndNoBody(t *testing.T) { // CRITICAL: CLI flags --min/--max must map to "minRange"/"maxRange" in JSON, // not "min"/"max". This is the field name the backend expects. -func TestAddSensor_RequestFieldNames(t *testing.T) { +func TestAddSensorRequestFieldNames(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - t.Errorf("method = %s, want POST", r.Method) + t.Errorf(fmtMethodWantPost, r.Method) } - if r.URL.Path != "/sim/gateways/7/sensors" { - t.Errorf("path = %s, want /sim/gateways/7/sensors", r.URL.Path) + if r.URL.Path != "/sim/gateways/gw-public-7/sensors" { + t.Errorf("path = %s, want /sim/gateways/gw-public-7/sensors", r.URL.Path) } assertContentType(t, r) @@ -260,55 +266,54 @@ func TestAddSensor_RequestFieldNames(t *testing.T) { // Field name must be "type" assertKey(t, body, "type", "temperature") - writeJSON(w, http.StatusCreated, client.Sensor{ID: 1}) + writeJSON(w, http.StatusCreated, client.Sensor{ID: "sensor-1"}) }) - _, err := c.AddSensor(7, client.AddSensorRequest{ + _, err := c.AddSensor("gw-public-7", client.AddSensorRequest{ Type: "temperature", MinRange: -10, MaxRange: 120, Algorithm: "sine_wave", }) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } } -// ── GET /sim/gateways/{id}/sensors — numeric ID in path ────────────────────── +// ── GET /sim/gateways/{id}/sensors — gateway ID in path ────────────────────── -func TestListSensors_NumericIDInPath(t *testing.T) { +func TestListSensorsGatewayIDInPath(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - // ID must be the numeric int64, not a UUID - if r.URL.Path != "/sim/gateways/1234567890/sensors" { - t.Errorf("path = %s, want /sim/gateways/1234567890/sensors", r.URL.Path) + if r.URL.Path != "/sim/gateways/gw-public-123/sensors" { + t.Errorf("path = %s, want /sim/gateways/gw-public-123/sensors", r.URL.Path) } writeJSON(w, http.StatusOK, []client.Sensor{}) }) - _, _ = c.ListSensors(1234567890) + _, _ = c.ListSensors("gw-public-123") } -// ── DELETE /sim/sensors/{sensorId} — numeric ID in path ────────────────────── +// ── DELETE /sim/sensors/{sensorId} — sensor ID in path ─────────────────────── -func TestDeleteSensor_NumericIDInPath(t *testing.T) { +func TestDeleteSensorSensorIDInPath(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("method = %s, want DELETE", r.Method) } - if r.URL.Path != "/sim/sensors/42" { - t.Errorf("path = %s, want /sim/sensors/42", r.URL.Path) + if r.URL.Path != "/sim/sensors/sensor-42" { + t.Errorf("path = %s, want /sim/sensors/sensor-42", r.URL.Path) } assertNoBody(t, r) w.WriteHeader(http.StatusNoContent) }) - _ = c.DeleteSensor(42) + _ = c.DeleteSensor("sensor-42") } // ── POST /sim/gateways/{id}/anomaly/disconnect — duration_seconds field ─────── -func TestDisconnect_RequestFieldNames(t *testing.T) { +func TestDisconnectRequestFieldNames(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sim/gateways/uuid-x/anomaly/disconnect" { - t.Errorf("path = %s", r.URL.Path) + if r.URL.Path != "/sim/gateways/"+testGatewayUUIDX+"/anomaly/disconnect" { + t.Errorf(fmtPathOnly, r.URL.Path) } assertContentType(t, r) @@ -320,15 +325,15 @@ func TestDisconnect_RequestFieldNames(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - _ = c.Disconnect("uuid-x", 7) + _ = c.Disconnect(testGatewayUUIDX, 7) } // ── POST /sim/gateways/{id}/anomaly/network-degradation ────────────────────── -func TestNetworkDegradation_RequestFieldNames(t *testing.T) { +func TestNetworkDegradationRequestFieldNames(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sim/gateways/uuid-x/anomaly/network-degradation" { - t.Errorf("path = %s", r.URL.Path) + if r.URL.Path != "/sim/gateways/"+testGatewayUUIDX+"/anomaly/network-degradation" { + t.Errorf(fmtPathOnly, r.URL.Path) } assertContentType(t, r) @@ -341,26 +346,26 @@ func TestNetworkDegradation_RequestFieldNames(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - _ = c.InjectNetworkDegradation("uuid-x", 15, 0.25) + _ = c.InjectNetworkDegradation(testGatewayUUIDX, 15, 0.25) } -func TestNetworkDegradation_PacketLossOmitted_WhenZero(t *testing.T) { +func TestNetworkDegradationPacketLossOmittedWhenZero(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { body := readBodyAsMap(t, r) // 0.0 with omitempty → field must be absent so backend applies its default (0.3) assertKeyAbsent(t, body, "packet_loss_pct") w.WriteHeader(http.StatusNoContent) }) - _ = c.InjectNetworkDegradation("uuid-x", 5, 0) + _ = c.InjectNetworkDegradation(testGatewayUUIDX, 5, 0) } // ── POST /sim/sensors/{sensorId}/anomaly/outlier ────────────────────────────── -func TestOutlier_RequestFieldNames_WithValue(t *testing.T) { +func TestOutlierRequestFieldNamesWithValue(t *testing.T) { val := 42.5 _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sim/sensors/99/anomaly/outlier" { - t.Errorf("path = %s, want /sim/sensors/99/anomaly/outlier", r.URL.Path) + if r.URL.Path != "/sim/sensors/sensor-99/anomaly/outlier" { + t.Errorf("path = %s, want /sim/sensors/sensor-99/anomaly/outlier", r.URL.Path) } assertContentType(t, r) @@ -369,17 +374,17 @@ func TestOutlier_RequestFieldNames_WithValue(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - _ = c.InjectOutlier(99, &val) + _ = c.InjectOutlier("sensor-99", &val) } -func TestOutlier_ValueOmitted_WhenNil(t *testing.T) { +func TestOutlierValueOmittedWhenNil(t *testing.T) { _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { body := readBodyAsMap(t, r) // nil pointer + omitempty → "value" key must be absent assertKeyAbsent(t, body, "value") w.WriteHeader(http.StatusNoContent) }) - _ = c.InjectOutlier(99, nil) + _ = c.InjectOutlier("sensor-99", nil) } // ── Default SIMULATOR_URL matches docker-compose service name ─────────────────