Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
99 changes: 99 additions & 0 deletions internal/device/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var sections = []dispatch.Section{
{
Title: "Devices",
Commands: []dispatch.Command{
{Name: "device pair", Arguments: "<imei> <fleet_id> [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: "<imei> <new_name>", Summary: "Rename a device", Run: device.Rename},
{Name: "device unpair", Arguments: "<imei>", Summary: "Remove a device from its fleet", Run: device.Unpair},
Expand Down
2 changes: 1 addition & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down