diff --git a/internal/commands/device_claim.go b/internal/commands/device_claim.go new file mode 100644 index 0000000..79d0d7f --- /dev/null +++ b/internal/commands/device_claim.go @@ -0,0 +1,87 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func DeviceClaim(arguments []string) error { + if len(arguments) != 2 && len(arguments) != 3 { + return errors.New("device claim 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") + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + fleetName := "" + + for _, fleet := range fleets { + if fleet.Id == fleetId { + fleetName = fleet.Name + } + } + + if fleetName == "" { + return errors.New("the fleet id is the number shown by fleet list") + } + + payload := map[string]string{"imei": imei} + + if len(arguments) == 3 { + payload["name"] = arguments[2] + } + + body, err := json.Marshal(payload) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/devices", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Claimed the device into %q.\n", fleetName) + + return nil +} diff --git a/internal/commands/device_claim_test.go b/internal/commands/device_claim_test.go new file mode 100644 index 0000000..748d51a --- /dev/null +++ b/internal/commands/device_claim_test.go @@ -0,0 +1,136 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +func TestDeviceClaim(t *testing.T) { + claimedImei := "" + claimedName := "" + + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Imei string `json:"imei"` + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + claimedImei = body.Imei + claimedName = body.Name + + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) + } + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return DeviceClaim([]string{"354820091234567", "3", "roof sensor"}) + }) + + if err != nil { + t.Fatal(err) + } + + if claimedImei != "354820091234567" || claimedName != "roof sensor" { + t.Errorf("the server received IMEI %q and name %q", claimedImei, claimedName) + } + + if printed != "Claimed the device into \"pilot\".\n" { + t.Errorf("output = %q", printed) + } +} + +func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { + nameWasPresent := false + + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { + body := map[string]string{} + json.NewDecoder(r.Body).Decode(&body) + _, nameWasPresent = body["name"] + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := DeviceClaim([]string{"354820091234567", "3"}) + + if err != nil { + t.Fatal(err) + } + + if nameWasPresent { + t.Error("the request included a name although none was given") + } +} + +func TestDeviceClaimServerError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no unclaimed device with that IMEI", http.StatusNotFound) + }) + + loggedInTestServer(t, mux) + + err := DeviceClaim([]string{"354820091234567", "3"}) + + if err == nil || err.Error() != "the server said: no unclaimed device with that IMEI" { + t.Fatalf("error = %v", err) + } +} + +func TestDeviceClaimArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI"}, + {"too many arguments", []string{"354820091234567", "3", "one", "two"}, "takes an IMEI"}, + {"short IMEI", []string{"123", "3"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x", "3"}, "15-digit"}, + {"wordy fleet", []string{"354820091234567", "pilot"}, "shown by fleet list"}, + {"zero fleet", []string{"354820091234567", "0"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := DeviceClaim(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) + } + } +} + +func TestDeviceClaimUnknownFleetUsesFleetIdGuidance(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + + loggedInTestServer(t, mux) + + err := DeviceClaim([]string{"354820091234567", "9"}) + + if err == nil || !strings.Contains(err.Error(), "shown by fleet list") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/commands/device_list.go b/internal/commands/device_list.go new file mode 100644 index 0000000..691de48 --- /dev/null +++ b/internal/commands/device_list.go @@ -0,0 +1,143 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "time" +) + +func DeviceList(arguments []string) error { + jsonOutput := false + positionals := []string{} + + for _, argument := range arguments { + if argument == "--json" { + jsonOutput = true + continue + } + + positionals = append(positionals, argument) + } + + if len(positionals) > 1 { + return errors.New("device list takes at most one fleet id") + } + + chosenFleetId := int64(0) + + if len(positionals) == 1 { + parsed, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || parsed < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + chosenFleetId = parsed + } + + devices, err := fetchDevices() + + if err != nil { + return err + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + filtered := []deviceEntry{} + + for _, device := range devices { + if chosenFleetId == 0 || device.FleetId == chosenFleetId { + filtered = append(filtered, device) + } + } + + if jsonOutput { + return json.NewEncoder(os.Stdout).Encode(filtered) + } + + if len(filtered) == 0 { + if chosenFleetId == 0 { + fmt.Println("No devices yet. Claim one with device claim.") + } else { + fmt.Println("No devices in that fleet.") + } + + return nil + } + + imeiWidth := len("IMEI") + nameWidth := len("NAME") + fleetWidth := len("FLEET") + lastSeenValues := make([]string, len(filtered)) + + for index, device := range filtered { + name := "-" + + if device.Name != nil { + name = *device.Name + } + + lastSeen := "never" + + if device.LastSeenAt != nil { + seenAt, err := time.Parse(time.RFC3339, *device.LastSeenAt) + + if err != nil { + return err + } + + age := time.Since(seenAt) + + switch { + case age < 2*time.Minute: + lastSeen = "just now" + case age < time.Hour: + lastSeen = fmt.Sprintf("%d min ago", int(age.Minutes())) + case age < 24*time.Hour: + lastSeen = fmt.Sprintf("%d h ago", int(age.Hours())) + default: + lastSeen = fmt.Sprintf("%d d ago", int(age.Hours()/24)) + } + } + + lastSeenValues[index] = lastSeen + imeiWidth = max(imeiWidth, len(device.Imei)) + nameWidth = max(nameWidth, len(name)) + fleetWidth = max(fleetWidth, len(fleetNames[device.FleetId])) + } + + fmt.Printf("%-*s %-*s %-*s %s\n", + imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", "LAST SEEN") + + for index, device := range filtered { + name := "-" + + if device.Name != nil { + name = *device.Name + } + + fmt.Printf("%-*s %-*s %-*s %s\n", + imeiWidth, device.Imei, nameWidth, name, fleetWidth, fleetNames[device.FleetId], lastSeenValues[index]) + } + + return nil +} diff --git a/internal/commands/device_list_test.go b/internal/commands/device_list_test.go new file mode 100644 index 0000000..bf706ca --- /dev/null +++ b/internal/commands/device_list_test.go @@ -0,0 +1,102 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +func TestDeviceList(t *testing.T) { + now := time.Now() + devices := fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q},`+ + `{"imei":"222222222222222","name":null,"fleet_id":4,"last_seen_at":%q},`+ + `{"imei":"333333333333333","name":"shed","fleet_id":3,"last_seen_at":null}]`, + now.Add(-time.Minute).Format(time.RFC3339), now.Add(-3*time.Hour).Format(time.RFC3339)) + fleets := `[{"id":3,"name":"pilot","owner":true},{"id":4,"name":"workshop","owner":true},{"id":5,"name":"empty","owner":true}]` + + tests := []struct { + name string + arguments []string + wantShown []string + wantHidden []string + wantExact string + wantError string + }{ + {"table", nil, []string{"IMEI NAME FLEET LAST SEEN", "roof", "pilot", "just now", "-", "workshop", "3 h ago", "never"}, nil, "", ""}, + {"filtered", []string{"3"}, []string{"111111111111111", "333333333333333"}, []string{"222222222222222", "workshop"}, "", ""}, + {"json flag anywhere", []string{"3", "--json"}, []string{`"imei":"111111111111111"`, `"fleet_id":3`}, []string{"LAST SEEN", "222222222222222"}, "", ""}, + {"empty fleet", []string{"5"}, nil, nil, "No devices in that fleet.\n", ""}, + {"unknown fleet", []string{"9"}, nil, nil, "", "no such fleet"}, + {"two ids", []string{"3", "4"}, nil, nil, "", "takes at most one fleet id"}, + {"wordy id", []string{"pilot"}, nil, nil, "", "shown by fleet list"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, devices) }) + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, fleets) }) + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { return DeviceList(test.arguments) }) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v", err) + } + return + } + + if err != nil { + t.Fatal(err) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("output = %q", printed) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("output %q omits %q", printed, want) + } + } + + for _, hidden := range test.wantHidden { + if strings.Contains(printed, hidden) { + t.Errorf("output %q includes %q", printed, hidden) + } + } + }) + } +} + +func TestDeviceListEmptyAndServerError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { return DeviceList(nil) }) + + if err != nil { + t.Fatal(err) + } + + if printed != "No devices yet. Claim one with device claim.\n" { + t.Errorf("output = %q", printed) + } + + errorMux := http.NewServeMux() + errorMux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "devices unavailable", http.StatusServiceUnavailable) + }) + loggedInTestServer(t, errorMux) + + err = DeviceList(nil) + + if err == nil || err.Error() != "the server said: devices unavailable" { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/commands/device_release.go b/internal/commands/device_release.go new file mode 100644 index 0000000..33604ba --- /dev/null +++ b/internal/commands/device_release.go @@ -0,0 +1,94 @@ +package commands + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +func DeviceRelease(arguments []string) error { + if len(arguments) != 1 { + return errors.New("device release takes an IMEI") + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + devices, err := fetchDevices() + + if err != nil { + return err + } + + fleetId := int64(0) + + for _, device := range devices { + if device.Imei == imei { + fleetId = device.FleetId + } + } + + if fleetId == 0 { + return errors.New("no such device, device list shows yours") + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + fleetName := "" + + for _, fleet := range fleets { + if fleet.Id == fleetId { + fleetName = fleet.Name + } + } + + if fleetName == "" { + return errors.New("no such device, device list shows yours") + } + + fmt.Printf("Release the device from %q? It can be claimed again afterwards. [y/N] ", fleetName) + + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Println("Nothing released.") + return nil + } + + request, err := authenticatedRequest(http.MethodDelete, "/devices/"+imei, nil) + + if err != nil { + return err + } + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Println("Released the device.") + + return nil +} diff --git a/internal/commands/device_release_test.go b/internal/commands/device_release_test.go new file mode 100644 index 0000000..4e08e7f --- /dev/null +++ b/internal/commands/device_release_test.go @@ -0,0 +1,106 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestDeviceRelease(t *testing.T) { + tests := []struct { + name string + answer string + refusal string + wantReleased bool + wantOutput string + wantError string + }{ + {"confirmed", "yes\n", "", true, "Release the device from \"pilot\"? It can be claimed again afterwards. [y/N] Released the device.\n", ""}, + {"declined", "n\n", "", false, "Release the device from \"pilot\"? It can be claimed again afterwards. [y/N] Nothing released.\n", ""}, + {"server refuses", "y\n", "no such device", true, "", "the server said: no such device"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + releasedPath := "" + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"imei":"354820091234567","name":null,"fleet_id":3,"last_seen_at":null}]`) + }) + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("DELETE /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { + releasedPath = r.URL.Path + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + answerOnStdin(t, test.answer) + + printed, err := captureStdout(t, func() error { + return DeviceRelease([]string{"354820091234567"}) + }) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + + if test.wantOutput != "" && printed != test.wantOutput { + t.Errorf("output = %q", printed) + } + + if test.wantReleased && releasedPath != "/devices/354820091234567" { + t.Errorf("released path = %q", releasedPath) + } + + if !test.wantReleased && releasedPath != "" { + t.Errorf("released path = %q after decline", releasedPath) + } + }) + } +} + +func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI"}, + {"two arguments", []string{"354820091234567", "extra"}, "takes an IMEI"}, + {"short IMEI", []string{"123"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x"}, "15-digit"}, + } + + for _, test := range tests { + err := DeviceRelease(test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v", test.name, err) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + loggedInTestServer(t, mux) + + err := DeviceRelease([]string{"354820091234567"}) + + if err == nil || err.Error() != "no such device, device list shows yours" { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/commands/device_rename.go b/internal/commands/device_rename.go new file mode 100644 index 0000000..dbb0441 --- /dev/null +++ b/internal/commands/device_rename.go @@ -0,0 +1,61 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func DeviceRename(arguments []string) error { + if len(arguments) != 2 { + return errors.New("device rename takes an IMEI and a name, quoted if it has spaces") + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + name := strings.TrimSpace(arguments[1]) + + if name == "" { + return errors.New("device rename takes an IMEI and a name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": name}) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPatch, "/devices/"+imei, bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Renamed the device to %q.\n", name) + + return nil +} diff --git a/internal/commands/device_rename_test.go b/internal/commands/device_rename_test.go new file mode 100644 index 0000000..184a9e6 --- /dev/null +++ b/internal/commands/device_rename_test.go @@ -0,0 +1,82 @@ +package commands + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestDeviceRename(t *testing.T) { + renamedPath := "" + renamedTo := "" + + mux := http.NewServeMux() + mux.HandleFunc("PATCH /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + renamedPath = r.URL.Path + renamedTo = body.Name + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return DeviceRename([]string{"354820091234567", " pilot "}) + }) + + if err != nil { + t.Fatal(err) + } + + if renamedPath != "/devices/354820091234567" || renamedTo != "pilot" { + t.Errorf("the server saw %q renamed to %q", renamedPath, renamedTo) + } + + if printed != "Renamed the device to \"pilot\".\n" { + t.Errorf("output = %q", printed) + } +} + +func TestDeviceRenameServerError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("PATCH /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no such device", http.StatusNotFound) + }) + + loggedInTestServer(t, mux) + + err := DeviceRename([]string{"354820091234567", "pilot"}) + + if err == nil || err.Error() != "the server said: no such device" { + t.Fatalf("error = %v", err) + } +} + +func TestDeviceRenameArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI and a name"}, + {"one argument", []string{"354820091234567"}, "takes an IMEI and a name"}, + {"three arguments", []string{"354820091234567", "roof", "sensor"}, "takes an IMEI and a name"}, + {"short IMEI", []string{"123", "pilot"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x", "pilot"}, "15-digit"}, + {"empty name", []string{"354820091234567", ""}, "takes an IMEI and a name"}, + {"whitespace name", []string{"354820091234567", " "}, "takes an IMEI and a name"}, + } + + for _, test := range tests { + err := DeviceRename(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) + } + } +} diff --git a/internal/commands/devices.go b/internal/commands/devices.go new file mode 100644 index 0000000..d5cd267 --- /dev/null +++ b/internal/commands/devices.go @@ -0,0 +1,62 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type deviceEntry struct { + Imei string `json:"imei"` + Name *string `json:"name"` + FleetId int64 `json:"fleet_id"` + LastSeenAt *string `json:"last_seen_at"` +} + +func fetchDevices() ([]deviceEntry, error) { + request, err := authenticatedRequest(http.MethodGet, "/devices", nil) + + if err != nil { + return nil, err + } + + response, err := apiClient.Do(request) + + if err != nil { + return nil, fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + devices := []deviceEntry{} + + err = json.NewDecoder(response.Body).Decode(&devices) + + if err != nil { + return nil, err + } + + return devices, nil +} + +func validImei(imei string) bool { + if len(imei) != 15 { + return false + } + + for _, digit := range imei { + if digit < '0' || digit > '9' { + return false + } + } + + return true +} diff --git a/main.go b/main.go index f34d43a..ca20bb4 100644 --- a/main.go +++ b/main.go @@ -44,10 +44,10 @@ var sections = []section{ { title: "Devices", commands: []command{ - {name: "device claim", arguments: " [name]", summary: "Claim a device into a fleet, then press its button"}, - {name: "device list", arguments: "[fleet_id]", summary: "List devices, their state, and when they were last seen"}, - {name: "device rename", arguments: " ", summary: "Rename a device"}, - {name: "device release", arguments: "", summary: "Unpair a device from its fleet and factory reset it"}, + {name: "device claim", arguments: " [name]", summary: "Claim a device into a fleet, then press its button", run: commands.DeviceClaim}, + {name: "device list", arguments: "[fleet_id]", summary: "List devices, their state, and when they were last seen", run: commands.DeviceList}, + {name: "device rename", arguments: " ", summary: "Rename a device", run: commands.DeviceRename}, + {name: "device release", arguments: "", summary: "Unpair a device from its fleet and factory reset it", run: commands.DeviceRelease}, {name: "device start", arguments: "", summary: "Run the code on the target"}, {name: "device stop", arguments: "", summary: "Halt the code on the target"}, {name: "device restart", arguments: "", summary: "Restart the code on the target"}, diff --git a/main_test.go b/main_test.go index dcde45f..7cf8c4e 100644 --- a/main_test.go +++ b/main_test.go @@ -86,10 +86,6 @@ func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { // them here is what stops a built one silently losing its wiring. version // and help are answered by main's own switch rather than by a run. planned := map[string]bool{ - "device claim": true, - "device list": true, - "device rename": true, - "device release": true, "device start": true, "device stop": true, "device restart": true,