package ankersolix import ( "context" "encoding/base64" "encoding/json" "strings" "testing" "time" "drivervault/apiserver/internal/mqtt" ) func TestMqttCredentialsAddressAndAppName(t *testing.T) { c := mqttCredentials{EndpointAddr: "aiot-mqtt-eu.anker.com"} if got := c.address(); got != "aiot-mqtt-eu.anker.com:8883" { t.Errorf("address = %q, want the TLS port appended", got) } // A response that already carries a port must not have another appended. c.EndpointAddr = "aiot-mqtt-eu.anker.com:8884" if got := c.address(); got != "aiot-mqtt-eu.anker.com:8884" { t.Errorf("address = %q, want the endpoint's own port kept", got) } if got := c.appName(); got != "anker_power" { t.Errorf("appName = %q, want the default when the cloud sent none", got) } c.AppName = "anker_charging" if got := c.appName(); got != "anker_charging" { t.Errorf("appName = %q, want the cloud's own value", got) } } func TestMqttCredentialsNeedCertificateAndKey(t *testing.T) { full := mqttCredentials{EndpointAddr: "host", CertificatePE: "cert", PrivateKey: "key"} if !full.valid() { t.Error("complete credentials were rejected") } for _, c := range []mqttCredentials{ {CertificatePE: "cert", PrivateKey: "key"}, {EndpointAddr: "host", PrivateKey: "key"}, {EndpointAddr: "host", CertificatePE: "cert"}, } { if c.valid() { t.Errorf("credentials missing a field were accepted: %+v", c) } } } func TestTopicsAddressOneCharger(t *testing.T) { c := mqttCredentials{AppName: "anker_power"} if got := commandTopic(c, "A5191", "SN123"); got != "cmd/anker_power/A5191/SN123/req" { t.Errorf("commandTopic = %q", got) } if got := dataTopic(c, "A5191", "SN123"); got != "dt/anker_power/A5191/SN123/#" { t.Errorf("dataTopic = %q", got) } } // envelope builds a message shaped like the ones the cloud delivers: JSON, whose // payload is itself a JSON string, whose data field is a base64 device frame. func envelope(t *testing.T, sn string, frame []byte) mqtt.Message { t.Helper() inner, err := json.Marshal(map[string]any{ "device_sn": sn, "data": base64.StdEncoding.EncodeToString(frame), }) if err != nil { t.Fatal(err) } outer, err := json.Marshal(map[string]any{ "head": map[string]any{"device_sn": sn, "timestamp": 1756813256}, "payload": string(inner), }) if err != nil { t.Fatal(err) } return mqtt.Message{Topic: "dt/anker_power/A5191/" + sn + "/param_info", Payload: outer} } func TestParseEnvelopeUnwrapsTheDeviceFrame(t *testing.T) { frame := buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)) sn, data, ok := parseEnvelope(envelope(t, "SN123", frame)) if !ok { t.Fatal("a well-formed envelope was rejected") } if sn != "SN123" { t.Errorf("serial = %q, want SN123", sn) } if string(data) != string(frame) { t.Errorf("frame came back changed") } } // The serial is not always repeated in the payload; the topic carries it too, // and a message we cannot attribute to a charger must be dropped rather than // folded into some other charger's state. func TestParseEnvelopeFallsBackToTheTopic(t *testing.T) { inner, _ := json.Marshal(map[string]any{"data": base64.StdEncoding.EncodeToString([]byte{1, 2, 3})}) outer, _ := json.Marshal(map[string]any{"payload": string(inner)}) sn, _, ok := parseEnvelope(mqtt.Message{Topic: "dt/anker_power/A5191/SN999/param_info", Payload: outer}) if !ok || sn != "SN999" { t.Errorf("serial = %q (ok=%v), want SN999 from the topic", sn, ok) } for _, bad := range []mqtt.Message{ {Topic: "dt/a/b/SN/x", Payload: []byte("not json")}, {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"not json"}`)}, {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"device_sn\":\"SN\"}"}`)}, // no data {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"data\":\"!!not b64\"}"}`)}, // undecodable {Topic: "short", Payload: []byte(`{"payload":"{\"data\":\"AQID\"}"}`)}, // no serial anywhere } { if _, _, ok := parseEnvelope(bad); ok { t.Errorf("a malformed envelope was accepted: %s", bad.Payload) } } } // A charger's state is assembled from two message types that arrive at different // times: telemetry must not erase the settings that came with the last command, // and vice versa. func TestIngestMergesTelemetryAndSettings(t *testing.T) { c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01)))) c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)))) values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1") if v, _ := values["maxCurrentSetA"].(float64); v != 32 { t.Errorf("the settings value was lost when telemetry arrived: %v", values["maxCurrentSetA"]) } if v, _ := values["status"].(float64); v != 2 { t.Errorf("status = %v, want 2", values["status"]) } if telemetryAt.IsZero() || settingsAt.IsZero() { t.Errorf("both halves should be timestamped: telemetry %v, settings %v", telemetryAt, settingsAt) } if !telemetryAt.After(settingsAt) && !telemetryAt.Equal(settingsAt) { t.Errorf("telemetry arrived second but is stamped earlier") } // A message from a charger we have no map for leaves the state untouched. c.ingest(mqtt.Message{Topic: "dt/a/b/SN1/x", Payload: []byte("rubbish")}) after, _, _, _ := c.snapshotOf("SN1") if len(after) != len(values) { t.Errorf("an unreadable message changed the charger's state") } } func TestWaitForReturnsWhenTheStateArrives(t *testing.T) { c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} go func() { time.Sleep(20 * time.Millisecond) c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)))) }() ok, err := c.waitFor(context.Background(), "SN1", func(st *deviceState) bool { return !st.telemetryAt.IsZero() }, 2*time.Second) if err != nil || !ok { t.Fatalf("waitFor = %v, %v; want it to see the message", ok, err) } } func TestWaitForGivesUpAndReportsADeadConnection(t *testing.T) { c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} ok, err := c.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 30*time.Millisecond) if err != nil || ok { t.Errorf("waitFor = %v, %v; want a quiet timeout", ok, err) } // A connection that dies while a caller is waiting must wake it with the // reason rather than making it sit out the whole timeout. c2 := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} go func() { time.Sleep(20 * time.Millisecond) c2.fail(mqtt.ErrClosed) }() start := time.Now() if _, err := c2.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 5*time.Second); err == nil { t.Error("a dropped connection ended the wait without an error") } if time.Since(start) > time.Second { t.Error("the waiter was not woken when the connection dropped") } } // A command that cannot be sent should be refused before it costs a sign-in, a // certificate fetch and a broker connection — so the check runs on a plugin with // no session at all. func TestMqttCommandValidatesBeforeReachingTheCloud(t *testing.T) { p := &Plugin{} for _, tc := range []struct { command string amps float64 want string }{ {"reboot", 0, "not a cloud command"}, {"limit", 3, "below the charger's 6 A floor"}, {"limit", 40, "outside the charger's range"}, } { _, err := p.mqttCommand(context.Background(), "SN1", tc.command, tc.amps) if err == nil { t.Errorf("%s(%v) was accepted", tc.command, tc.amps) continue } if !strings.Contains(err.Error(), tc.want) { t.Errorf("%s(%v) failed with %q, want it to mention %q", tc.command, tc.amps, err, tc.want) } } // A valid command gets past validation and fails on the missing session // instead, which is what proves the order. if _, err := p.mqttCommand(context.Background(), "SN1", "start", 0); err == nil || !strings.Contains(err.Error(), "not initialised") { t.Errorf("start failed with %v, want it to reach the session lookup", err) } } func TestMqttModeValuesMatchTheChargerEnum(t *testing.T) { want := map[string]uint8{ modeStartCharge: 1, modeStopCharge: 2, modeSkipDelay: 3, modeBoostCharge: 4, } for mode, v := range want { if mqttModeValues[mode] != v { t.Errorf("%s = %d, want %d", mode, mqttModeValues[mode], v) } } // Every accepted command name must resolve to one of those modes, or the // endpoint can offer a name the transport cannot send. for name, mode := range mqttCommands { if _, ok := mqttModeValues[mode]; !ok { t.Errorf("command %q maps to %q, which is not a charger mode", name, mode) } } } func TestClientIDDoesNotCollideWithTheApp(t *testing.T) { creds := mqttCredentials{ThingName: "abc-anker_power"} a, b := clientIDFor(creds), clientIDFor(creds) if !strings.HasPrefix(a, "abc-anker_power_") { t.Errorf("client id %q does not carry the account's thing name", a) } if a == b { t.Error("two connections were given the same client id, which would evict each other") } // With no thing name the user id stands in, so the id is still account-scoped. if got := clientIDFor(mqttCredentials{UserID: "u1"}); !strings.HasPrefix(got, "u1_") { t.Errorf("client id %q does not fall back to the user id", got) } }