package ankersolix import ( "strings" "testing" ) // A charger that sits in a station is absent from the standalone list even // though the account count includes it — the case that made a good login look // like an account with no chargers. func TestStandaloneListMissesStationChargers(t *testing.T) { body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`) inv := newInventory() inv.addStandalone(body) if got := len(inv.list()); got != 0 { t.Fatalf("standalone view: got %d chargers, want 0", got) } if n, ok := countChargers(body); !ok || n != 2 { t.Fatalf("countChargers = %d, %v; want 2, true", n, ok) } // The site view is where those two actually live. scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2}, {"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`) inv.addStates(parseScenePiles(scene, "site-1"), "Home") list := inv.list() if len(list) != 2 { t.Fatalf("after site view: got %d chargers, want 2", len(list)) } if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" { t.Fatalf("first charger = %+v", list[0]) } if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" { t.Fatalf("status/power not carried over: %+v", list[0]) } if list[0].OcppStatusDesc != "connected" { t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc) } } func TestAddStandaloneReadsHesFieldNames(t *testing.T) { body := []byte(`{"code":0,"data":{"evChargers":[ {"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true}, {"evChargerName":"nameless"}]}}`) inv := newInventory() inv.addStandalone(body) list := inv.list() if len(list) != 1 { t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list)) } c := list[0] if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging { t.Fatalf("charger = %+v", c) } if c.Online == nil || !*c.Online { t.Fatalf("online = %v, want true", c.Online) } if len(c.Sources) != 1 || c.Sources[0] != "standalone" { t.Fatalf("sources = %v", c.Sources) } } // Bound devices discover EV chargers by product family and enrich chargers the // other views already found — without dragging in the rest of the account. func TestAddBoundFiltersAndEnriches(t *testing.T) { inv := newInventory() scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","operating_state":0}]}}}`) inv.addStates(parseScenePiles(scene, "site-1"), "Home") bound := []byte(`{"code":0,"data":{"data":[ {"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true}, {"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"}, {"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`) inv.addBound(bound) list := inv.list() if len(list) != 2 { t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list)) } if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" { t.Fatalf("enriched charger = %+v", list[0]) } if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" { t.Fatalf("sources = %v, want [site bound]", list[0].Sources) } if list[1].SN != "EVSN9" || list[1].Name != "Carport" { t.Fatalf("discovered charger = %+v", list[1]) } } // The site view knows the live state; the standalone view saw the charger // first. Merging must not let the later view blank what the earlier one knew. func TestMergeKeepsFirstKnownValues(t *testing.T) { inv := newInventory() inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`)) inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[ {"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home") list := inv.list() if len(list) != 1 { t.Fatalf("got %d chargers, want 1", len(list)) } c := list[0] if c.Name != "Garage" { t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name) } if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" { t.Fatalf("site view did not fill the gaps: %+v", c) } } func TestDataListCandidateKeys(t *testing.T) { body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`) got := dataList(body, "evChargers", "chargerList") if len(got) != 2 { t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got)) } if dataList([]byte(`not json`), "data") != nil { t.Fatal("malformed body should yield no list") } if dataList([]byte(`{"data":{}}`), "missing") != nil { t.Fatal("absent key should yield no list") } } func TestPickers(t *testing.T) { m := map[string]any{"a": " x ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)} if got := pickString(m, "b", "a"); got != "x" { t.Fatalf("pickString = %q, want x", got) } if got := pickString(m, "n"); got != "7" { t.Fatalf("pickString(number) = %q, want 7", got) } if got := pickString(m, "missing"); got != "" { t.Fatalf("pickString(missing) = %q, want empty", got) } if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 { t.Fatalf("pickInt = %d, %v; want 42, true", n, ok) } if b, ok := pickBool(m, "t"); !ok || !b { t.Fatalf("pickBool = %v, %v; want true, true", b, ok) } if b, ok := pickBool(m, "z"); !ok || b { t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok) } if _, ok := pickBool(m, "a"); ok { t.Fatal("a non-boolean string must not read as a bool") } } func TestFillString(t *testing.T) { s := "" fillString(&s, " first ") fillString(&s, "second") if s != "first" { t.Fatalf("s = %q, want first", s) } } // Everything a view sent is kept, not only the fields the merge has a name for: // nested objects and arrays arrive under the path the cloud nested them at, and // a field that says nothing leaves no row behind. func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) { inv := newInventory() inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage", "wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":" ","absent":null}]}}`)) attrs := inv.list()[0].Attrs want := map[string]string{ "evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true", "rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b", } for k, v := range want { if attrs[k] != v { t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v) } } for _, k := range []string{"blank", "absent"} { if _, ok := attrs[k]; ok { t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k]) } } // A second view fills gaps without overwriting what the first one answered. inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`)) attrs = inv.list()[0].Attrs if attrs["rssi"] != "-52" { t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"]) } if attrs["bt_mac"] != "AA:BB" { t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"]) } } // The station record is the only view that answers for a charger standing // outside a station, so it fills the live fields the list views left empty — // without claiming the charger is registered anywhere new. func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) { inv := newInventory() inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`)) inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4", "ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`)) c := inv.list()[0] if c.StatusDesc != stateCharging || c.Power != "7.4" { t.Fatalf("station view did not fill the live fields: %+v", c) } if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" { t.Fatalf("station view did not fill the named fields: %+v", c) } if c.Attrs["plug_temperature"] != "41" { t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"]) } if len(c.Sources) != 1 || c.Sources[0] != "standalone" { t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources) } // A serial no view found is not a charger this pass may invent. inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`)) if len(inv.list()) != 1 { t.Fatalf("got %d chargers, want 1", len(inv.list())) } } func TestRawRecordsDescendsToANestedList(t *testing.T) { body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`) got := rawRecords(body, "charging_pile_info", "charging_pile_list") if len(got) != 1 || got[0]["power"] != "7.4" { t.Fatalf("rawRecords = %v", got) } if rawRecords(body, "charging_pile_info") != nil { t.Fatal("an object where a list was asked for should yield nothing") } if rawRecords([]byte(`{"data":[]}`), "missing") != nil { t.Fatal("a data that is not an object should yield nothing") } } // The bound-device view carries the charger's state and its OCPP link under // names of its own — chargerStatus, not evChargerStatus — which is how a card // ends up printing a dash beside a value the same response contains. func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) { inv := newInventory() inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK", "chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT", "wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen", "link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`)) c := inv.list()[0] if c.StatusDesc != stateCharging { t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging) } if c.OcppStatusDesc != "connected" { t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc) } if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" { t.Errorf("networks = %+v", c) } if c.WifiRSSI == nil || *c.WifiRSSI != -63 { t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI) } if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" { t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL) } if c.LinkedAt == nil || *c.LinkedAt != 1788186620 { t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt) } if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" { t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy) } } // A device record carries the charger's Bluetooth pairing password. That the // field exists is reportable; its value is not something a card should carry. func TestAttrsMaskCredentials(t *testing.T) { inv := newInventory() inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191", "blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`)) attrs := inv.list()[0].Attrs for _, key := range []string{"blue_password", "cloud.auth_token"} { if attrs[key] == "" { t.Fatalf("attrs[%q] is absent; the field should still be reported", key) } if strings.ContainsAny(attrs[key], "ATabc") { t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key]) } } if attrs["wifi_name"] != "AP_1_IOT" { t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"]) } }