0108 was the one thing in the MQTT inventory nobody had wired: the device power mode, whose single documented value restarts the charger. It is the only way to reboot a charger that is on neither a CSMS nor the local network — which is most of them — so the cloud transport sends it now, and "reset" reaches it too, since that is what the OCPP path has always called the same act. Nothing waits for a confirmation: the device that would send it is the device rebooting, so the command answers at once and says the charger drops off the cloud for about a minute. The gate is unchanged and now covers both spellings — an explicit confirm plus a password step-up, audited either way. Modbus still refuses, because no register does this, but its refusal now names both transports that can rather than only the CSMS. Both clients already had the reset button and its password prompt; they were hidden in every mode that reads the device, which is why the cloud never showed one. Modbus is now the only mode without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
4.7 KiB
Go
128 lines
4.7 KiB
Go
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)))
|
|
}
|
|
}
|