package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // refuse runs one action through the cloud transport and returns the response. // Only the actions refused up front reach the plugin-free path, which is exactly // what these cases cover. func refuse(t *testing.T, action string, body ankerControlBody) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/integrations/anker-solix/control/SN1/"+action, nil) (&Server{}).ankerMqttAction(rec, req, nil, ankerResolution{}, "SN1", action, body) return rec } // An action this transport cannot send must say which transport can, so the // answer tells the operator what to change rather than that something is // unknown. func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) { for _, tc := range []struct{ action, want string }{ {"unlock", "CSMS"}, {"availability", "CSMS"}, {"config", "CSMS"}, {"phase", "Modbus TCP"}, {"timeout", "Modbus TCP"}, {"clear-limit", "with the amps you want"}, } { rec := refuse(t, tc.action, ankerControlBody{}) if rec.Code != http.StatusBadRequest { t.Errorf("%s returned %d, want 400", tc.action, rec.Code) continue } if !strings.Contains(rec.Body.String(), tc.want) { t.Errorf("%s answered %q, want it to mention %q", tc.action, rec.Body.String(), tc.want) } } } // A reboot is a reboot under either name and over any transport: both go through // the confirmation and the password step-up, and nothing else does. func TestBothNamesForARebootAreGated(t *testing.T) { for _, action := range []string{"reset", "restart", "unlock"} { if !isDestructiveAction(action) { t.Errorf("%s should need a confirmation and a password", action) } } for _, action := range []string{"start", "stop", "limit", "boost", "settings", "status"} { if isDestructiveAction(action) { t.Errorf("%s should not demand a password", action) } } } func TestAnkerMqttActionRejectsUnknownActions(t *testing.T) { rec := refuse(t, "explode", ankerControlBody{}) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unknown control action") { t.Errorf("unknown action returned %d %q", rec.Code, rec.Body.String()) } } // Boost runs until the session ends and there is no message to cancel it, so // asking to switch it off has to be refused rather than quietly sending a boost. func TestAnkerMqttActionRefusesTurningBoostOff(t *testing.T) { off := false rec := refuse(t, "boost", ankerControlBody{On: &off}) if rec.Code != http.StatusBadRequest { t.Fatalf("boost off returned %d, want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "ends with the charging session") { t.Errorf("boost off answered %q, want it to explain when boost ends", rec.Body.String()) } } // A settings write with nothing to write is a mistake worth naming, not an empty // command published to the charger. func TestAnkerMqttActionRequiresSettingsToWrite(t *testing.T) { rec := refuse(t, "settings", ankerControlBody{}) if rec.Code != http.StatusBadRequest { t.Fatalf("empty settings returned %d, want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "settings") { t.Errorf("empty settings answered %q, want it to name the field it wants", rec.Body.String()) } } // The cloud transport signs in as the account, so unlike Modbus it carries the // caller's resolved credentials into the plugin call. func TestAnkerCloudConfigCarriesTheResolvedCredentials(t *testing.T) { cfg := ankerCloudConfig(ankerResolution{ eff: ankerConfig{Email: "a@example.com", Password: "secret", Country: "DK"}, }) if cfg["email"] != "a@example.com" || cfg["password"] != "secret" || cfg["country"] != "DK" { t.Errorf("config = %v, want the resolved credentials", cfg) } if _, ok := cfg["controlMode"]; ok { t.Error("the control mode is the API server's business, not the plugin's") } } func TestMustJSONEncodesTheCommandPayload(t *testing.T) { var got map[string]any if err := json.Unmarshal(mustJSON(map[string]any{"sn": "SN1", "command": "limit", "amps": 16.0}), &got); err != nil { t.Fatalf("mustJSON produced invalid JSON: %v", err) } if got["sn"] != "SN1" || got["command"] != "limit" || got["amps"] != 16.0 { t.Errorf("payload = %v", got) } } func TestShortenDetailKeepsTheAnswerReadable(t *testing.T) { if got := shortenDetail(" broke\nbadly "); got != "broke badly" { t.Errorf("shortenDetail = %q", got) } if got := shortenDetail(""); got != "no detail" { t.Errorf("shortenDetail of nothing = %q", got) } long := shortenDetail(strings.Repeat("x", 400)) if len([]rune(long)) != 201 || !strings.HasSuffix(long, "…") { t.Errorf("a long detail was not trimmed: %d runes", len([]rune(long))) } }