package ocpp import ( "sync/atomic" "testing" "time" "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBootNotificationStoresResultAndConnects(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") assert.False(t, cp.Connected(), "should not be connected initially") assert.Nil(t, cp.BootNotificationResult, "should have no boot result initially") bootReq := &core.BootNotificationRequest{ ChargePointModel: "TestModel", ChargePointVendor: "TestVendor", } res, err := cp.OnBootNotification(bootReq) require.NoError(t, err) assert.Equal(t, core.RegistrationStatusAccepted, res.Status) // should be connected after BootNotification assert.True(t, cp.Connected(), "should be connected after BootNotification") assert.Equal(t, bootReq, cp.BootNotificationResult, "should store boot result") // should have sent to channel select { case req := <-cp.bootNotificationRequestC: assert.Equal(t, bootReq, req) case <-time.After(100 * time.Millisecond): t.Fatal("bootNotificationRequestC should have received notification") } } func TestBootNotificationStopsTimer(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") // simulate WebSocket connect (starts timer) cp.onTransportConnect() // timer should be set cp.mu.RLock() assert.NotNil(t, cp.bootTimer, "boot timer should be active after transport connect") cp.mu.RUnlock() // BootNotification arrives - should stop timer and connect bootReq := &core.BootNotificationRequest{ ChargePointModel: "TestModel", ChargePointVendor: "TestVendor", } _, err := cp.OnBootNotification(bootReq) require.NoError(t, err) assert.True(t, cp.Connected(), "should be connected after BootNotification") cp.mu.RLock() assert.Nil(t, cp.bootTimer, "boot timer should be nil after BootNotification") cp.mu.RUnlock() // drain the channel <-cp.bootNotificationRequestC } func TestTransportConnectTimeoutFallback(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") // use a short timeout for testing origTimeout := Timeout Timeout = 50 * time.Millisecond defer func() { Timeout = origTimeout }() assert.False(t, cp.Connected(), "should not be connected initially") cp.onTransportConnect() // should not be connected yet assert.False(t, cp.Connected(), "should not be connected immediately after transport connect") // should be connected after timeout (fallback) require.Eventually(t, cp.Connected, time.Second, 10*time.Millisecond, "should be connected after boot timeout") // boot timer should be cleared after firing cp.mu.RLock() assert.Nil(t, cp.bootTimer, "boot timer should be nil after timeout") cp.mu.RUnlock() } func TestDisconnectCancelsTimer(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") // use a short timeout for testing origTimeout := Timeout Timeout = 200 * time.Millisecond defer func() { Timeout = origTimeout }() cp.onTransportConnect() cp.mu.RLock() assert.NotNil(t, cp.bootTimer, "boot timer should be active") cp.mu.RUnlock() // disconnect should cancel timer cp.connect(false) cp.mu.RLock() assert.Nil(t, cp.bootTimer, "boot timer should be nil after disconnect") cp.mu.RUnlock() // should still be disconnected (timer was cancelled) require.Never(t, cp.Connected, 300*time.Millisecond, 10*time.Millisecond, "should not be connected after cancelled timer") } func TestBootNotificationChannelFull(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") // fill the channel (buffer size 1) cp.bootNotificationRequestC <- &core.BootNotificationRequest{ ChargePointModel: "First", } // second notification should be dropped (channel full, non-blocking send) bootReq := &core.BootNotificationRequest{ ChargePointModel: "Second", ChargePointVendor: "TestVendor", } res, err := cp.OnBootNotification(bootReq) require.NoError(t, err) assert.Equal(t, core.RegistrationStatusAccepted, res.Status) // result should still be updated even though channel was full assert.Equal(t, bootReq, cp.BootNotificationResult) assert.True(t, cp.Connected()) // channel should have the first message (second was dropped) req := <-cp.bootNotificationRequestC assert.Equal(t, "First", req.ChargePointModel) } func TestReconnectAfterReboot(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") // simulate initial connection cp.connect(true) // simulate disconnect cp.connect(false) assert.False(t, cp.Connected()) // simulate reconnect with BootNotification (reboot) cp.onTransportConnect() bootReq := &core.BootNotificationRequest{ ChargePointModel: "TestModel", ChargePointVendor: "TestVendor", } _, err := cp.OnBootNotification(bootReq) require.NoError(t, err) assert.True(t, cp.Connected(), "should be connected after reboot BootNotification") // channel should have the notification for monitorReboot to pick up select { case req := <-cp.bootNotificationRequestC: assert.Equal(t, bootReq, req) case <-time.After(100 * time.Millisecond): t.Fatal("bootNotificationRequestC should have received reboot notification") } } func TestMonitorRebootOnlyOnce(t *testing.T) { log := util.NewLogger("test") cp := NewChargePoint(log, "test-cp") ctx := t.Context() var callCount atomic.Int32 setup := func() error { callCount.Add(1); return nil } cp.MonitorReboot(ctx, setup) cp.MonitorReboot(ctx, setup) cp.MonitorReboot(ctx, setup) // send a boot notification to trigger the monitor cp.bootNotificationRequestC <- &core.BootNotificationRequest{ ChargePointModel: "TestModel", } // wait for the goroutine to process it require.Eventually(t, func() bool { return callCount.Load() == 1 }, time.Second, 10*time.Millisecond, "setup should be called exactly once") }