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
87 changes: 87 additions & 0 deletions internal/commands/device_claim.go
Original file line number Diff line number Diff line change
@@ -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
}
136 changes: 136 additions & 0 deletions internal/commands/device_claim_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
143 changes: 143 additions & 0 deletions internal/commands/device_list.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading