From 40c6b1c3615ac41c6baf17b344a48bffe17fc46d Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 30 Sep 2026 16:29:17 +0200 Subject: [PATCH] Add device start, stop, and restart Each command posts to the server's route for it and reports that the device acts at its next check-in. A refusal from the server is shown as the server wrote it. --- internal/device/device.go | 52 +++++++++++++ internal/device/device_test.go | 132 +++++++++++++++++++++++++++++++++ main.go | 6 +- main_test.go | 9 +-- 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/internal/device/device.go b/internal/device/device.go index 169447e..54e757e 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -402,6 +402,58 @@ func Unpair(invocation api.Invocation, arguments []string) error { return nil } +func Start(invocation api.Invocation, arguments []string) error { + err := sendInstruction(invocation, arguments, "start") + + return err +} + +func Stop(invocation api.Invocation, arguments []string) error { + err := sendInstruction(invocation, arguments, "stop") + + return err +} + +func Restart(invocation api.Invocation, arguments []string) error { + err := sendInstruction(invocation, arguments, "restart") + + return err +} + +func sendInstruction(invocation api.Invocation, arguments []string, instruction string) error { + if len(arguments) != 1 { + return fmt.Errorf("device %s takes an IMEI", instruction) + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/devices/"+imei+"/"+instruction, nil) + + if err != nil { + return err + } + + response, err := invocation.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, "Device %s %ss its code at its next check-in.\n", imei, instruction) + + return nil +} + func validImei(imei string) bool { if len(imei) != 15 { return false diff --git a/internal/device/device_test.go b/internal/device/device_test.go index bfea528..62aeda8 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -3,7 +3,9 @@ package device import ( "encoding/json" "fmt" + "io" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -412,6 +414,136 @@ func TestDeviceUnpair(t *testing.T) { } } +func TestDeviceStartStopAndRestart(t *testing.T) { + commands := []struct { + name string + run func(api.Invocation, []string) error + wantRequest string + wantOutput string + }{ + {"start", Start, "POST /devices/354820091234567/start", "Device 354820091234567 starts its code at its next check-in.\n"}, + {"stop", Stop, "POST /devices/354820091234567/stop", "Device 354820091234567 stops its code at its next check-in.\n"}, + {"restart", Restart, "POST /devices/354820091234567/restart", "Device 354820091234567 restarts its code at its next check-in.\n"}, + } + + tests := []struct { + name string + status int + refusal string + unreachable bool + wantError string + }{ + {name: "accepted", status: http.StatusNoContent}, + {name: "a device outside the user's fleets", status: http.StatusNotFound, refusal: "no such device", wantError: "no such device"}, + {name: "refused", status: http.StatusConflict, refusal: "no code has been uploaded to this device", wantError: "no code has been uploaded to this device"}, + {name: "unreachable server", unreachable: true, wantError: "the server could not be reached, check your internet access"}, + } + + for _, command := range commands { + for _, test := range tests { + t.Run(command.name+" "+test.name, func(t *testing.T) { + sawRequest := "" + sawBody := "" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + + if err != nil { + t.Error(err) + } + + sawRequest = r.Method + " " + r.URL.Path + sawBody = string(body) + + if test.refusal != "" { + http.Error(w, test.refusal, test.status) + return + } + + w.WriteHeader(test.status) + }) + + invocation, out := apitest.LoggedInInvocation(t, handler) + + wantRequest := command.wantRequest + wantOutput := command.wantOutput + + if test.unreachable { + gone := httptest.NewServer(http.NotFoundHandler()) + gone.Close() + + invocation.Base = gone.URL + wantRequest = "" + } + + if test.wantError != "" { + wantOutput = "" + } + + err := command.run(invocation, []string{"354820091234567"}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if sawRequest != wantRequest { + t.Errorf("the server saw %q, want %q", sawRequest, wantRequest) + } + + if sawBody != "" { + t.Errorf("the server saw the body %q, want none", sawBody) + } + + if out.String() != wantOutput { + t.Errorf("output = %q, want %q", out.String(), wantOutput) + } + }) + } + } +} + +func TestDeviceStartStopAndRestartArguments(t *testing.T) { + commands := []struct { + name string + run func(api.Invocation, []string) error + wantUsage string + }{ + {"start", Start, "device start takes an IMEI"}, + {"stop", Stop, "device stop takes an IMEI"}, + {"restart", Restart, "device restart takes an IMEI"}, + } + + tests := []struct { + name string + arguments []string + wantError string + }{ + {name: "no arguments"}, + {name: "two arguments", arguments: []string{"354820091234567", "extra"}}, + {name: "short IMEI", arguments: []string{"123"}, wantError: "the IMEI is the 15-digit number printed on the device"}, + {name: "non-digit IMEI", arguments: []string{"35482009123456x"}, wantError: "the IMEI is the 15-digit number printed on the device"}, + } + + for _, command := range commands { + for _, test := range tests { + wantError := test.wantError + + if wantError == "" { + wantError = command.wantUsage + } + + err := command.run(api.Invocation{}, test.arguments) + + if err == nil || err.Error() != wantError { + t.Errorf("%s %s: error = %v, want %q", command.name, test.name, err, wantError) + } + } + } +} + func TestDeviceUnpairArgumentsAndUnknownDevice(t *testing.T) { tests := []struct { name string diff --git a/main.go b/main.go index fcef161..166e2cd 100644 --- a/main.go +++ b/main.go @@ -41,9 +41,9 @@ var sections = []dispatch.Section{ {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}, - {Name: "device start", Arguments: "", Summary: "Start the code on a device"}, - {Name: "device stop", Arguments: "", Summary: "Stop the code on a device"}, - {Name: "device restart", Arguments: "", Summary: "Restart the code on a device"}, + {Name: "device start", Arguments: "", Summary: "Start the code on a device", Run: device.Start}, + {Name: "device stop", Arguments: "", Summary: "Stop the code on a device", Run: device.Stop}, + {Name: "device restart", Arguments: "", Summary: "Restart the code on a device", Run: device.Restart}, }, }, { diff --git a/main_test.go b/main_test.go index 9f8a8e4..ac95ef7 100644 --- a/main_test.go +++ b/main_test.go @@ -43,11 +43,8 @@ func TestCommandTable(t *testing.T) { func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { plannedCommands := map[string]bool{ - "device start": true, - "device stop": true, - "device restart": true, - "dev": true, - "tail": true, + "dev": true, + "tail": true, } answeredByDispatch := map[string]bool{ "version": true, @@ -160,7 +157,7 @@ func TestNoPartImportsAnother(t *testing.T) { func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", - "device list", "device pair", "device rename", "device unpair", + "device list", "device pair", "device rename", "device restart", "device start", "device stop", "device unpair", "download", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", "key create", "key list", "key revoke",