You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mynewt.apache.org by ja...@apache.org on 2026/10/23 22:02:20 UTC

(mynewt-newtmgr) 14/20: newtmgr/cli: Add unit tests for res, log and image helpers

This is an automated email from the ASF dual-hosted git repository.

sjanc pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mynewt-newtmgr.git

commit 7793220d7a9526e6cee06949b84feb410ec61163
Author: Szymon Janc <sz...@codecoup.pl>
AuthorDate: Thu Oct 22 00:23:09 2026 +0200

    newtmgr/cli: Add unit tests for res, log and image helpers
    
    Cover the pure helper functions used by the res, log and image
    commands: k=v resource specifier parsing, payload encoding from k=v
    pairs, JSON arguments and raw/JSON/binary payload files, float to int
    conversion for --int, CoAP response formatting, log show argument
    parsing, CBOR log message to JSON conversion, and image flag strings.
    
    Include regression tests for earlier res fixes: k=v pairs must not be
    discarded (80e5c28), and no payload arguments must yield no payload
    rather than an empty CBOR map (d988f6d, 0d6802e).
    
    Assisted-by: Claude:claude-opus-5-5
---
 newtmgr/cli/image_test.go |  51 ++++++
 newtmgr/cli/log_test.go   | 123 +++++++++++++
 newtmgr/cli/res_test.go   | 429 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 603 insertions(+)

diff --git a/newtmgr/cli/image_test.go b/newtmgr/cli/image_test.go
new file mode 100644
index 0000000..453ecdd
--- /dev/null
+++ b/newtmgr/cli/image_test.go
@@ -0,0 +1,51 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package cli
+
+import (
+	"testing"
+
+	"mynewt.apache.org/newtmgr/nmxact/nmp"
+)
+
+func TestImageFlagsStr(t *testing.T) {
+	tests := []struct {
+		img  nmp.ImageStateEntry
+		want string
+	}{
+		{nmp.ImageStateEntry{}, ""},
+		{nmp.ImageStateEntry{Active: true}, "active"},
+		{nmp.ImageStateEntry{Active: true, Confirmed: true},
+			"active confirmed"},
+		{nmp.ImageStateEntry{Pending: true, Permanent: true},
+			"pending permanent"},
+		{nmp.ImageStateEntry{Permanent: true, Pending: true,
+			Confirmed: true, Active: true, Bootable: true},
+			"active confirmed pending permanent"},
+		{nmp.ImageStateEntry{Bootable: true}, ""},
+	}
+
+	for _, tt := range tests {
+		if got := imageFlagsStr(tt.img); got != tt.want {
+			t.Errorf("imageFlagsStr(%+v) = %q, want %q", tt.img, got,
+				tt.want)
+		}
+	}
+}
diff --git a/newtmgr/cli/log_test.go b/newtmgr/cli/log_test.go
new file mode 100644
index 0000000..f28a94d
--- /dev/null
+++ b/newtmgr/cli/log_test.go
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package cli
+
+import (
+	"strings"
+	"testing"
+
+	"mynewt.apache.org/newtmgr/nmxact/nmxutil"
+)
+
+func TestLogShowParseArgs(t *testing.T) {
+	tests := []struct {
+		name string
+		args []string
+		want logShowCfg
+	}{
+		{"none", nil, logShowCfg{}},
+		{"name", []string{"reboot_log"}, logShowCfg{Name: "reboot_log"}},
+		{"index", []string{"log", "10"}, logShowCfg{Name: "log", Index: 10}},
+		{"hex index", []string{"log", "0x10"},
+			logShowCfg{Name: "log", Index: 16}},
+		{"max index", []string{"log", "4294967295"},
+			logShowCfg{Name: "log", Index: 0xffffffff}},
+		{"last", []string{"log", "last"},
+			logShowCfg{Name: "log", Timestamp: -1}},
+		{"last ignores timestamp", []string{"log", "last", "junk"},
+			logShowCfg{Name: "log", Timestamp: -1}},
+		{"index and timestamp", []string{"log", "3", "123456"},
+			logShowCfg{Name: "log", Index: 3, Timestamp: 123456}},
+		{"negative timestamp", []string{"log", "3", "-7"},
+			logShowCfg{Name: "log", Index: 3, Timestamp: -7}},
+		{"extra args ignored", []string{"log", "1", "2", "3"},
+			logShowCfg{Name: "log", Index: 1, Timestamp: 2}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg, err := logShowParseArgs(tt.args)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if *cfg != tt.want {
+				t.Errorf("got %+v, want %+v", *cfg, tt.want)
+			}
+		})
+	}
+}
+
+func TestLogShowParseArgsErrors(t *testing.T) {
+	tests := []struct {
+		args []string
+		err  string
+	}{
+		{[]string{"log", "4294967296"}, "index out of range"},
+		{[]string{"log", "-1"}, ""},
+		{[]string{"log", "abc"}, ""},
+		{[]string{"log", "1", "ts"}, ""},
+	}
+
+	for _, tt := range tests {
+		cfg, err := logShowParseArgs(tt.args)
+		if err == nil {
+			t.Errorf("%v: expected error, got %+v", tt.args, cfg)
+			continue
+		}
+		if tt.err != "" && err.Error() != tt.err {
+			t.Errorf("%v: error = %q, want %q", tt.args, err.Error(), tt.err)
+		}
+	}
+}
+
+func TestLogCborMsgText(t *testing.T) {
+	b, err := nmxutil.EncodeCbor(map[string]interface{}{
+		"msg": "hello",
+		"n":   7,
+		"neg": -1,
+		"l":   []interface{}{1, "x"},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := logCborMsgText(b)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// encoding/json sorts map keys.
+	want := `{"l":[1,"x"],"msg":"hello","n":7,"neg":-1}`
+	if got != want {
+		t.Errorf("got %s, want %s", got, want)
+	}
+}
+
+func TestLogCborMsgTextInvalid(t *testing.T) {
+	// Not a map.
+	if _, err := logCborMsgText([]byte{0x01}); err == nil {
+		t.Errorf("expected error for non-map CBOR")
+	}
+
+	// Truncated map.
+	_, err := logCborMsgText([]byte{0xa1, 0x61})
+	if err == nil || !strings.HasPrefix(err.Error(), "failure decoding cbor") {
+		t.Errorf("unexpected error: %v", err)
+	}
+}
diff --git a/newtmgr/cli/res_test.go b/newtmgr/cli/res_test.go
new file mode 100644
index 0000000..239bdb2
--- /dev/null
+++ b/newtmgr/cli/res_test.go
@@ -0,0 +1,429 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package cli
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/runtimeco/go-coap"
+
+	"mynewt.apache.org/newtmgr/nmxact/nmxutil"
+)
+
+func TestExtractResKv(t *testing.T) {
+	tests := []struct {
+		name   string
+		params []string
+		want   map[string]interface{}
+	}{
+		{"none", nil, map[string]interface{}{}},
+		{"int", []string{"a=1"}, map[string]interface{}{"a": 1}},
+		{"negative int", []string{"a=-5"}, map[string]interface{}{"a": -5}},
+		{"string", []string{"a=hello"},
+			map[string]interface{}{"a": "hello"}},
+		{"quoted int stays string", []string{`a="12"`},
+			map[string]interface{}{"a": "12"}},
+		{"quoted bool stays string", []string{`a="true"`},
+			map[string]interface{}{"a": "true"}},
+		{"empty quoted string", []string{`a=""`},
+			map[string]interface{}{"a": ""}},
+		{"only leading quote", []string{`a="x`},
+			map[string]interface{}{"a": `"x`}},
+		{"bool true", []string{"a=true"}, map[string]interface{}{"a": true}},
+		{"bool mixed case", []string{"a=FaLsE"},
+			map[string]interface{}{"a": false}},
+		{"float stays string", []string{"a=1.5"},
+			map[string]interface{}{"a": "1.5"}},
+		{"empty value", []string{"a="}, map[string]interface{}{"a": ""}},
+		{"value with equals", []string{"a=b=c"},
+			map[string]interface{}{"a": "b=c"}},
+		{"empty key", []string{"=1"}, map[string]interface{}{"": 1}},
+		{"multiple", []string{"x=1", "y=two", "z=false"},
+			map[string]interface{}{"x": 1, "y": "two", "z": false}},
+		{"duplicate key last wins", []string{"x=1", "x=2"},
+			map[string]interface{}{"x": 2}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m, err := extractResKv(tt.params)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if !reflect.DeepEqual(m, tt.want) {
+				t.Errorf("got %#v, want %#v", m, tt.want)
+			}
+		})
+	}
+}
+
+func TestExtractResKvErrors(t *testing.T) {
+	for _, p := range []string{"", "abc"} {
+		m, err := extractResKv([]string{"ok=1", p})
+		if err == nil {
+			t.Errorf("%q: expected error, got %v", p, m)
+			continue
+		}
+		want := "invalid resource specifier: " + p
+		if err.Error() != want {
+			t.Errorf("error = %q, want %q", err.Error(), want)
+		}
+	}
+}
+
+// normCbor converts a decoded CBOR value into a form that is easy to compare:
+// maps get string keys and integers become int64.
+func normCbor(v interface{}) interface{} {
+	switch x := v.(type) {
+	case map[interface{}]interface{}:
+		m := map[string]interface{}{}
+		for k, e := range x {
+			m[k.(string)] = normCbor(e)
+		}
+		return m
+	case []interface{}:
+		for i := range x {
+			x[i] = normCbor(x[i])
+		}
+		return x
+	case uint64:
+		return int64(x)
+	default:
+		return x
+	}
+}
+
+func decodeCbor(t *testing.T, b []byte) interface{} {
+	t.Helper()
+	v, err := nmxutil.DecodeCbor(b)
+	if err != nil {
+		t.Fatalf("DecodeCbor(%x): %v", b, err)
+	}
+	return normCbor(v)
+}
+
+// setResGlobals sets the res command flag variables for the duration of a
+// test.
+func setResGlobals(t *testing.T, json, toInt bool, jsonFile, rawFile,
+	binFile string) {
+
+	t.Helper()
+
+	oJson, oInt := resJson, resInt
+	oJf, oRf, oBf := resJsonFilename, resRawFilename, resBinFilename
+	t.Cleanup(func() {
+		resJson, resInt = oJson, oInt
+		resJsonFilename, resRawFilename, resBinFilename = oJf, oRf, oBf
+	})
+
+	resJson, resInt = json, toInt
+	resJsonFilename, resRawFilename, resBinFilename = jsonFile, rawFile,
+		binFile
+}
+
+func TestParsePayloadNoArgs(t *testing.T) {
+	// Regression (d988f6d, 0d6802e): no payload arguments must produce no
+	// payload at all, not an empty CBOR map.
+	for _, json := range []bool{false, true} {
+		setResGlobals(t, json, false, "", "", "")
+
+		for _, args := range [][]string{nil, {}} {
+			b, err := parsePayload(args)
+			if err != nil {
+				t.Fatalf("json=%v: unexpected error: %v", json, err)
+			}
+			if b != nil {
+				t.Errorf("json=%v: payload = %x, want nil", json, b)
+			}
+
+			b, err = calcCborPayload(args)
+			if err != nil {
+				t.Fatalf("json=%v: unexpected error: %v", json, err)
+			}
+			if b != nil {
+				t.Errorf("json=%v: payload = %x, want nil", json, b)
+			}
+		}
+	}
+}
+
+func TestParsePayloadKv(t *testing.T) {
+	setResGlobals(t, false, false, "", "", "")
+
+	// Regression (80e5c28): k=v pairs must not be discarded.
+	b, err := parsePayload([]string{"a=1", "b=str", "c=true", `d="7"`})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	want := map[string]interface{}{
+		"a": int64(1), "b": "str", "c": true, "d": "7",
+	}
+	if got := decodeCbor(t, b); !reflect.DeepEqual(got, want) {
+		t.Errorf("payload = %#v, want %#v", got, want)
+	}
+
+	if _, err := parsePayload([]string{"novalue"}); err == nil {
+		t.Errorf("expected error for invalid specifier")
+	}
+}
+
+func TestParsePayloadJsonArg(t *testing.T) {
+	tests := []struct {
+		name  string
+		toInt bool
+		arg   string
+		want  interface{}
+	}{
+		{"object float", false, `{"a": 2, "b": [1.5, "x"]}`,
+			map[string]interface{}{
+				"a": float64(2),
+				"b": []interface{}{1.5, "x"},
+			}},
+		{"object int", true, `{"a": 2, "b": [1.5, 3.0, {"c": -4}]}`,
+			map[string]interface{}{
+				"a": int64(2),
+				"b": []interface{}{1.5, int64(3),
+					map[string]interface{}{"c": int64(-4)}},
+			}},
+		{"scalar", true, `5`, int64(5)},
+		{"string", false, `"hi"`, "hi"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setResGlobals(t, true, tt.toInt, "", "", "")
+
+			b, err := parsePayload([]string{tt.arg, "ignored"})
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got := decodeCbor(t, b); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("payload = %#v, want %#v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestParsePayloadJsonNull(t *testing.T) {
+	setResGlobals(t, true, false, "", "", "")
+
+	// A JSON null is treated as "no payload".
+	b, err := parsePayload([]string{"null"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if b != nil {
+		t.Errorf("payload = %x, want nil", b)
+	}
+}
+
+func TestParsePayloadJsonInvalid(t *testing.T) {
+	setResGlobals(t, true, false, "", "", "")
+
+	if _, err := parsePayload([]string{"{bad"}); err == nil {
+		t.Errorf("expected error for invalid JSON")
+	}
+	if _, err := parsePayloadJson(""); err == nil {
+		t.Errorf("expected error for empty JSON")
+	}
+}
+
+func TestRemoveFloats(t *testing.T) {
+	tests := []struct {
+		in   interface{}
+		want interface{}
+	}{
+		{float64(2), int64(2)},
+		{float64(-3), int64(-3)},
+		{float64(0), int64(0)},
+		{1.5, 1.5},
+		{float32(4), int64(4)},
+		{float32(0.5), float64(0.5)},
+		{"s", "s"},
+		{true, true},
+		{nil, nil},
+		{[]interface{}{1.0, 2.5, "x"}, []interface{}{int64(1), 2.5, "x"}},
+		{map[string]interface{}{"a": 1.0, "b": []interface{}{7.0}},
+			map[string]interface{}{"a": int64(1),
+				"b": []interface{}{int64(7)}}},
+	}
+
+	for _, tt := range tests {
+		got := removeFloats(tt.in)
+		if !reflect.DeepEqual(got, tt.want) {
+			t.Errorf("removeFloats(%#v) = %#v, want %#v", tt.in, got, tt.want)
+		}
+	}
+}
+
+func writeTemp(t *testing.T, name string, data []byte) string {
+	t.Helper()
+	p := filepath.Join(t.TempDir(), name)
+	if err := os.WriteFile(p, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+func TestCalcCborPayloadRawFile(t *testing.T) {
+	raw := []byte{0xa1, 0x61, 0x61, 0x01, 0xff, 0x00}
+	setResGlobals(t, false, false, "", writeTemp(t, "raw", raw), "")
+
+	// The raw file is sent verbatim and arguments are ignored.
+	b, err := calcCborPayload([]string{"a=2"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !reflect.DeepEqual(b, raw) {
+		t.Errorf("payload = %x, want %x", b, raw)
+	}
+}
+
+func TestCalcCborPayloadJsonFile(t *testing.T) {
+	p := writeTemp(t, "j.json", []byte(`{"a": 3.0, "b": "x"}`))
+	setResGlobals(t, false, true, p, "", "")
+
+	b, err := calcCborPayload(nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	want := map[string]interface{}{"a": int64(3), "b": "x"}
+	if got := decodeCbor(t, b); !reflect.DeepEqual(got, want) {
+		t.Errorf("payload = %#v, want %#v", got, want)
+	}
+
+	bad := writeTemp(t, "bad.json", []byte(`{`))
+	setResGlobals(t, false, false, bad, "", "")
+	if _, err := calcCborPayload(nil); err == nil {
+		t.Errorf("expected error for invalid JSON file")
+	}
+}
+
+func TestCalcCborPayloadBinFile(t *testing.T) {
+	data := []byte{0, 1, 2, 0xfe}
+	setResGlobals(t, false, false, "", "", writeTemp(t, "bin", data))
+
+	b, err := calcCborPayload(nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// Encoded as a CBOR byte string.
+	want := append([]byte{0x44}, data...)
+	if !reflect.DeepEqual(b, want) {
+		t.Errorf("payload = %x, want %x", b, want)
+	}
+}
+
+func TestCalcCborPayloadMissingFile(t *testing.T) {
+	missing := filepath.Join(t.TempDir(), "missing")
+
+	cases := []struct {
+		name              string
+		jsonF, rawF, binF string
+	}{
+		{"json", missing, "", ""},
+		{"raw", "", missing, ""},
+		{"bin", "", "", missing},
+	}
+	for _, c := range cases {
+		setResGlobals(t, false, false, c.jsonF, c.rawF, c.binF)
+		if _, err := calcCborPayload(nil); err == nil {
+			t.Errorf("%s: expected error for missing file", c.name)
+		}
+	}
+}
+
+func TestCborValStr(t *testing.T) {
+	tests := []struct {
+		in   interface{}
+		want string
+	}{
+		{"abc", "abc"},
+		{uint64(5), "0x5"},
+		{int64(-5), "-5"},
+		{true, "true"},
+		{[]byte{0x01, 0x02},
+			"00000000  01 02                                             |..|"},
+	}
+
+	for _, tt := range tests {
+		if got := cborValStr(tt.in); got != tt.want {
+			t.Errorf("cborValStr(%#v) = %q, want %q", tt.in, got, tt.want)
+		}
+	}
+}
+
+func TestPrintCode(t *testing.T) {
+	tests := []struct {
+		code coap.COAPCode
+		want string
+	}{
+		{coap.Content, "CoAP Response Code: 2.05 Content\n"},
+		{coap.NotFound, "CoAP Response Code: 4.04 NotFound\n"},
+		{coap.Created, "CoAP Response Code: 2.01 Created\n"},
+	}
+
+	for _, tt := range tests {
+		if got := printCode(tt.code); got != tt.want {
+			t.Errorf("printCode(%d) = %q, want %q", tt.code, got, tt.want)
+		}
+	}
+}
+
+func TestCleanUpMapValue(t *testing.T) {
+	in := map[interface{}]interface{}{
+		"s":       "str",
+		uint64(1): []interface{}{int64(-1), true, nil},
+		"m":       map[interface{}]interface{}{"x": 1.5},
+	}
+	want := map[string]interface{}{
+		"s": "str",
+		"1": []interface{}{"-1", "true", "<nil>"},
+		"m": map[string]interface{}{"x": "1.5"},
+	}
+
+	if got := cleanUpMapValue(in); !reflect.DeepEqual(got, want) {
+		t.Errorf("got %#v, want %#v", got, want)
+	}
+}
+
+func TestResResponseStr(t *testing.T) {
+	if got := resResponseStr("/a/b", nil); got != "/a/b\n    <empty>" {
+		t.Errorf("empty payload: got %q", got)
+	}
+
+	b, err := nmxutil.EncodeCbor(map[string]interface{}{"k": 1})
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "/a/b\n{\n    \"k\": \"1\"\n}"
+	if got := resResponseStr("/a/b", b); got != want {
+		t.Errorf("got %q, want %q", got, want)
+	}
+
+	got := resResponseStr("/a/b", []byte{0xff, 0xff})
+	if !strings.HasPrefix(got, "/a/b\n    invalid incoming cbor:") {
+		t.Errorf("invalid cbor: got %q", got)
+	}
+}