package actuator import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "github.com/tb/ems/internal/config" "github.com/tb/ems/internal/engine" ) // mockShelly simulates both Gen1 and Gen2 Shelly HTTP APIs. type mockShelly struct { state bool // current relay state calls []string } func (m *mockShelly) handler() http.Handler { mux := http.NewServeMux() // Gen1: GET /relay/0?turn=on|off or GET /relay/0 (read state) mux.HandleFunc("/relay/0", func(w http.ResponseWriter, r *http.Request) { m.calls = append(m.calls, r.Method+" "+r.URL.String()) if turn := r.URL.Query().Get("turn"); turn != "" { m.state = turn == "on" } json.NewEncoder(w).Encode(map[string]interface{}{"ison": m.state}) }) // Gen2: POST /rpc/Switch.Set or POST /rpc/Switch.GetStatus mux.HandleFunc("/rpc/Switch.Set", func(w http.ResponseWriter, r *http.Request) { m.calls = append(m.calls, r.Method+" "+r.URL.Path) var body struct { On bool `json:"on"` } json.NewDecoder(r.Body).Decode(&body) wasOn := m.state m.state = body.On json.NewEncoder(w).Encode(map[string]interface{}{"was_on": wasOn}) }) mux.HandleFunc("/rpc/Switch.GetStatus", func(w http.ResponseWriter, r *http.Request) { m.calls = append(m.calls, r.Method+" "+r.URL.Path) json.NewEncoder(w).Encode(map[string]interface{}{"output": m.state}) }) return mux } func (m *mockShelly) start(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(m.handler()) t.Cleanup(srv.Close) return srv } // ipFrom extracts host:port from an httptest server URL. func ipFrom(srv *httptest.Server) string { u, _ := url.Parse(srv.URL) return u.Host } func testActuator(t *testing.T, sgReadyIP, wallboxAIP, wallboxBIP string) *Actuator { t.Helper() cfg := &config.Config{ Shelly: config.ShellyConfig{ SGReady: config.ShellyDevice{IP: sgReadyIP, Gen: 1}, WallboxA: config.ShellyDevice{IP: wallboxAIP, Gen: 2}, WallboxB: config.ShellyDevice{IP: wallboxBIP, Gen: 2}, }, } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) return NewActuator(cfg, nil, logger) } func TestExecuteTurnOnSGReady(t *testing.T) { sg := &mockShelly{} sgSrv := sg.start(t) dummy := &mockShelly{} dummySrv := dummy.start(t) act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv)) errs := act.Execute(context.Background(), []engine.Action{ {Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"}, }) if errs[0] != nil { t.Fatalf("Execute failed: %v", errs[0]) } if !sg.state { t.Error("SG-Ready should be ON after TurnOn action") } if len(sg.calls) != 1 || !strings.Contains(sg.calls[0], "turn=on") { t.Errorf("expected one GET /relay/0?turn=on call, got %v", sg.calls) } } func TestExecuteTurnOffWallboxA(t *testing.T) { wbA := &mockShelly{state: true} // starts ON wbASrv := wbA.start(t) dummy := &mockShelly{} dummySrv := dummy.start(t) act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv)) errs := act.Execute(context.Background(), []engine.Action{ {Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"}, }) if errs[0] != nil { t.Fatalf("Execute failed: %v", errs[0]) } if wbA.state { t.Error("Wallbox A should be OFF after TurnOff action") } } func TestReadAllStates(t *testing.T) { sg := &mockShelly{state: true} sgSrv := sg.start(t) wbA := &mockShelly{state: false} wbASrv := wbA.start(t) wbB := &mockShelly{state: true} wbBSrv := wbB.start(t) act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv)) states, err := act.ReadAllStates(context.Background()) if err != nil { t.Fatalf("ReadAllStates failed: %v", err) } if !states[engine.ConsumerSGReady].On { t.Error("SG-Ready should be ON") } if states[engine.ConsumerWallboxA].On { t.Error("Wallbox A should be OFF") } if !states[engine.ConsumerWallboxB].On { t.Error("Wallbox B should be ON") } } func TestExecuteMultipleActions(t *testing.T) { sg := &mockShelly{} sgSrv := sg.start(t) wbA := &mockShelly{} wbASrv := wbA.start(t) wbB := &mockShelly{} wbBSrv := wbB.start(t) act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv)) for i, err := range act.Execute(context.Background(), []engine.Action{ {Consumer: engine.ConsumerSGReady, TurnOn: true}, {Consumer: engine.ConsumerWallboxA, TurnOn: true}, {Consumer: engine.ConsumerWallboxB, TurnOn: true}, }) { if err != nil { t.Fatalf("Execute action %d failed: %v", i, err) } } if !sg.state || !wbA.state || !wbB.state { t.Error("all consumers should be ON") } }