evcc-io/charger/ocpp/forwarder_test.go
2026-08-20 13:36:53 +02:00

174 lines
4.5 KiB
Go

package ocpp
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
"github.com/lorenzodonini/ocpp-go/ocppj"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testBootFrame = `[2,"orig-id","BootNotification",{"chargePointVendor":"v","chargePointModel":"m"}]`
func TestForwarderWithMessageID(t *testing.T) {
frame, err := withMessageID([]byte(testBootFrame), "new-id")
require.NoError(t, err)
msgType, msgID, action, err := parseOCPPFrame(frame)
require.NoError(t, err)
assert.Equal(t, ocppj.CALL, msgType)
assert.Equal(t, "new-id", msgID)
assert.Equal(t, "BootNotification", action)
assert.Contains(t, string(frame), `"chargePointVendor":"v"`)
_, err = withMessageID([]byte(`{}`), "new-id")
assert.Error(t, err)
}
// fakeChannel implements ws.Channel for hook tests
type fakeChannel string
func (c fakeChannel) ID() string { return string(c) }
func (c fakeChannel) RemoteAddr() net.Addr { return nil }
func (c fakeChannel) TLSConnectionState() *tls.ConnectionState { return nil }
func (c fakeChannel) IsConnected() bool { return true }
func TestForwarderBootNotificationCache(t *testing.T) {
const id = "fwd-boot-cache-test"
onChargerMessage(fakeChannel(id), []byte(testBootFrame))
bootMu.Lock()
boot := lastBoot[id]
bootMu.Unlock()
assert.Equal(t, testBootFrame, string(boot))
onChargerDisconnect(fakeChannel(id))
bootMu.Lock()
boot = lastBoot[id]
bootMu.Unlock()
assert.Nil(t, boot)
}
// forwarderTestSetup marks a charger as connected with a cached boot frame and
// fast reconnect, returning a cleanup function.
func forwarderTestSetup(t *testing.T, id string) func() {
t.Helper()
delay := reconnectInitialDelay
reconnectInitialDelay = 10 * time.Millisecond
sidecarsMu.Lock()
connectedChargers[id] = true
sidecarsMu.Unlock()
bootMu.Lock()
lastBoot[id] = []byte(testBootFrame)
bootMu.Unlock()
return func() {
ApplyForwarderRules(nil)
sidecarsMu.Lock()
delete(connectedChargers, id)
sidecarsMu.Unlock()
bootMu.Lock()
delete(lastBoot, id)
bootMu.Unlock()
reconnectInitialDelay = delay
}
}
// expectBootReplay asserts that a replayed BootNotification with a fresh message id arrives.
func expectBootReplay(t *testing.T, frames <-chan []byte) {
t.Helper()
select {
case data := <-frames:
msgType, msgID, action, err := parseOCPPFrame(data)
require.NoError(t, err)
assert.Equal(t, ocppj.CALL, msgType)
assert.Equal(t, "BootNotification", action)
assert.NotEqual(t, "orig-id", msgID)
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for upstream reconnect")
}
}
func TestForwarderUpstreamReconnect(t *testing.T) {
const id = "fwd-reconnect-test"
var conns atomic.Int32
frames := make(chan []byte, 1)
srv := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"ocpp1.6"}})
if err != nil {
return
}
if conns.Add(1) == 1 {
// simulate upstream dropping the connection without close handshake
c.CloseNow()
return
}
if _, data, err := c.Read(r.Context()); err == nil {
select {
case frames <- data:
default:
}
}
c.CloseNow()
}))
srv.Start()
cleanup := forwarderTestSetup(t, id)
defer cleanup()
ApplyForwarderRules([]ForwarderRule{{StationID: id, UpstreamURL: "ws" + strings.TrimPrefix(srv.URL, "http")}})
expectBootReplay(t, frames)
assert.GreaterOrEqual(t, conns.Load(), int32(2))
}
func TestForwarderDialRetry(t *testing.T) {
const id = "fwd-dial-retry-test"
var reqs atomic.Int32
frames := make(chan []byte, 1)
srv := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if reqs.Add(1) <= 2 {
// reject the websocket upgrade to simulate an unreachable upstream
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"ocpp1.6"}})
if err != nil {
return
}
if _, data, err := c.Read(r.Context()); err == nil {
select {
case frames <- data:
default:
}
}
c.CloseNow()
}))
srv.Start()
cleanup := forwarderTestSetup(t, id)
defer cleanup()
ApplyForwarderRules([]ForwarderRule{{StationID: id, UpstreamURL: "ws" + strings.TrimPrefix(srv.URL, "http")}})
expectBootReplay(t, frames)
assert.GreaterOrEqual(t, reqs.Load(), int32(3))
}