diff --git a/internal/device/device.go b/internal/device/device.go index 9d1e8cf..bdd9eb7 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -14,6 +14,83 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) +func Pair(invocation api.Invocation, arguments []string) error { + if len(arguments) < 2 || len(arguments) > 3 { + return errors.New("device pair takes an IMEI, a fleet id, and an optional name") + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + fleetID, err := strconv.ParseInt(arguments[1], 10, 64) + + if err != nil || fleetID < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + requestBody := struct { + IMEI string `json:"imei"` + Name *string `json:"name,omitempty"` + }{ + IMEI: imei, + } + + label := imei + + if len(arguments) == 3 { + name := strings.TrimSpace(arguments[2]) + + if name == "" { + return errors.New("the optional device name cannot be empty") + } + + requestBody.Name = &name + label = name + } + + body, err := json.Marshal(requestBody) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetID, 10)+"/devices", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + fmt.Fprintf(invocation.Out, "Press the pairing button on device %q.\n", label) + + client := *invocation.Client + + if client.Timeout > 0 && client.Timeout < 65*time.Second { + client.Timeout = 65 * time.Second + } + + response, err := client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(invocation.Out, "Paired device %q with fleet %d.\n", label, fleetID) + + return nil +} + func List(invocation api.Invocation, arguments []string) error { positionals, jsonOutput := api.TakeJsonFlag(arguments) diff --git a/internal/device/device_test.go b/internal/device/device_test.go index d88b319..def2f19 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -107,6 +107,105 @@ func TestDeviceList(t *testing.T) { } } +func TestDevicePair(t *testing.T) { + tests := []struct { + name string + arguments []string + wantBody string + wantOutput string + }{ + { + name: "without a name", + arguments: []string{"354820091234567", "3"}, + wantBody: `{"imei":"354820091234567"}`, + wantOutput: "Press the pairing button on device \"354820091234567\".\nPaired device \"354820091234567\" with fleet 3.\n", + }, + { + name: "with a name", + arguments: []string{"354820091234567", "3", " rooftop "}, + wantBody: `{"imei":"354820091234567","name":"rooftop"}`, + wantOutput: "Press the pairing button on device \"rooftop\".\nPaired device \"rooftop\" with fleet 3.\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := "" + mux := http.NewServeMux() + mux.HandleFunc("POST /fleets/3/devices", func(w http.ResponseWriter, r *http.Request) { + decoded := map[string]any{} + + if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { + t.Fatal(err) + } + + encoded, err := json.Marshal(decoded) + + if err != nil { + t.Fatal(err) + } + + body = string(encoded) + w.WriteHeader(http.StatusNoContent) + }) + + invocation, out := apitest.LoggedInInvocation(t, mux) + + err := Pair(invocation, test.arguments) + + if err != nil { + t.Fatal(err) + } + + if body != test.wantBody { + t.Errorf("body = %q, want %q", body, test.wantBody) + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestDevicePairArgumentsAndRefusal(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI"}, + {"one argument", []string{"354820091234567"}, "takes an IMEI"}, + {"four arguments", []string{"354820091234567", "3", "roof", "extra"}, "takes an IMEI"}, + {"short IMEI", []string{"123", "3"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x", "3"}, "15-digit"}, + {"zero fleet id", []string{"354820091234567", "0"}, "fleet id"}, + {"unreadable fleet id", []string{"354820091234567", "crew"}, "fleet id"}, + {"empty name", []string{"354820091234567", "3", " "}, "cannot be empty"}, + } + + for _, test := range tests { + err := Pair(api.Invocation{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /fleets/3/devices", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no such device", http.StatusNotFound) + }) + + invocation, _ := apitest.LoggedInInvocation(t, mux) + + err := Pair(invocation, []string{"354820091234567", "3"}) + + if err == nil || err.Error() != "no such device" { + t.Fatalf("error = %v, want no such device", err) + } +} + func TestDeviceRename(t *testing.T) { tests := []struct { name string diff --git a/main.go b/main.go index 85632a9..26aa628 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ var sections = []dispatch.Section{ { Title: "Devices", Commands: []dispatch.Command{ + {Name: "device pair", Arguments: " [name]", Summary: "Pair a device with a fleet", Run: device.Pair}, {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices and when they were last seen", Run: device.List}, {Name: "device rename", Arguments: " ", Summary: "Rename a device", Run: device.Rename}, {Name: "device unpair", Arguments: "", Summary: "Remove a device from its fleet", Run: device.Unpair}, diff --git a/main_test.go b/main_test.go index 096fb2e..42b986f 100644 --- a/main_test.go +++ b/main_test.go @@ -161,7 +161,7 @@ func TestNoPartImportsAnother(t *testing.T) { func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", - "device list", "device rename", "device unpair", + "device list", "device pair", "device rename", "device unpair", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", "key create", "key list", "key revoke", "login", "logout",