Skip to content
Open
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
52 changes: 52 additions & 0 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions internal/device/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package device
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<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},
{Name: "device start", Arguments: "<imei>", Summary: "Start the code on a device"},
{Name: "device stop", Arguments: "<imei>", Summary: "Stop the code on a device"},
{Name: "device restart", Arguments: "<imei>", Summary: "Restart the code on a device"},
{Name: "device start", Arguments: "<imei>", Summary: "Start the code on a device", Run: device.Start},
{Name: "device stop", Arguments: "<imei>", Summary: "Stop the code on a device", Run: device.Stop},
{Name: "device restart", Arguments: "<imei>", Summary: "Restart the code on a device", Run: device.Restart},
},
},
{
Expand Down
9 changes: 3 additions & 6 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading