Files
DriverVault/API Server/internal/api/integrations_ankersolix_test.go
T
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:40:22 +02:00

97 lines
3.1 KiB
Go

package api
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestNormalizeControlMode(t *testing.T) {
cases := map[string]string{
"off": "off",
"own": "own",
"proxy": "proxy",
"OWN": "own",
" Proxy": "proxy",
"": "", // unset — cascade continues to the next layer
"bogus": "", // unknown — treated as unset
}
for in, want := range cases {
if got := normalizeControlMode(in); got != want {
t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want)
}
}
}
func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-old"}})
if _, ok := ci.lookup("tok-old"); !ok {
t.Fatal("tok-old should resolve after first set")
}
// Regenerate: same charger, new token. The old token must stop resolving.
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-new"}})
if _, ok := ci.lookup("tok-old"); ok {
t.Error("tok-old should be invalidated after regeneration")
}
b, ok := ci.lookup("tok-new")
if !ok || b.UserID != "u1" || b.Serial != "SN1" {
t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok)
}
}
func TestControlIndexIsolatesUsers(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a"}})
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": {Token: "b"}})
// Re-setting u1 must not touch u2's token.
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a2"}})
if _, ok := ci.lookup("b"); !ok {
t.Error("u2's token should be untouched when u1 is updated")
}
}
func TestAnkerBindingFor(t *testing.T) {
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"token":"xyz"}}}}`)
if got := ankerBindingFor(raw, "SN1").Token; got != "xyz" {
t.Errorf("binding token = %q, want xyz", got)
}
if got := ankerBindingFor(raw, "other").Token; got != "" {
t.Errorf("unknown charger should have empty token, got %q", got)
}
if got := ankerBindingFor(nil, "SN1").Token; got != "" {
t.Errorf("nil settings should have empty token, got %q", got)
}
}
func TestExtractWSURL(t *testing.T) {
cases := map[string]string{
`{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1",
`{"a":{"b":[{"endpoint":"ws://x/y"}]}}`: "ws://x/y",
`{"data":{"note":"https://not-a-ws-url"}}`: "",
`{"empty":true}`: "",
}
for in, want := range cases {
if got := extractWSURL(json.RawMessage(in)); got != want {
t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want)
}
}
}
func TestOCPPEndpoint(t *testing.T) {
s := &Server{}
// Plain HTTP request → ws://
r := httptest.NewRequest("GET", "http://host.example/x", nil)
r.Host = "host.example"
if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" {
t.Errorf("endpoint = %q", got)
}
// Behind a TLS-terminating proxy → wss://
r2 := httptest.NewRequest("GET", "http://host.example/x", nil)
r2.Host = "host.example"
r2.Header.Set("X-Forwarded-Proto", "https")
if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" {
t.Errorf("tls endpoint = %q", got)
}
}