Nik Afiq b54a747467
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 53s
CI / build-ha-gateway (push) Successful in 42s
CI / build-discord-bot (push) Successful in 40s
feat: add switch control functionality to the Home Assistant integration
- Implemented TurnOn, TurnOff, and Toggle methods in the gRPC client for switch control.
- Added corresponding methods in the CommandApp to handle user commands for turning switches on, off, and toggling.
- Created unit tests for the new switch control methods in CommandApp and SwitchApp.
- Updated the HAGateway interface to include switch control methods.
- Enhanced the SwitchGRPC service to handle switch control requests and return appropriate responses.
- Added integration tests for the switch service to ensure correct behavior and error handling.
2026-07-22 23:44:35 +09:00

298 lines
8.7 KiB
Go

package app
import (
"context"
"errors"
"reflect"
"testing"
"time"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
)
func TestSwitchAppRefresh(t *testing.T) {
t.Run("filters non-switch entities and populates cache", func(t *testing.T) {
ha := &mockHAClient{
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
return []*driven.HAState{
{
EntityID: "switch.fan",
State: "on",
Attributes: map[string]any{
"friendly_name": "Fan",
"device_class": "switch",
},
},
{
EntityID: "light.kitchen",
State: "on",
},
{
EntityID: "switch.heater",
State: "off",
Attributes: map[string]any{
"friendly_name": "Heater",
},
},
}, nil
},
}
app := NewSwitchApp(ha)
if err := app.Refresh(context.Background()); err != nil {
t.Fatalf("Refresh() error = %v", err)
}
got := app.cache
want := []domain.Switch{
{EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "switch"},
{EntityID: "switch.heater", FriendlyName: "Heater", State: "off"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("cache = %#v, want %#v", got, want)
}
})
}
func TestSwitchAppListSwitches(t *testing.T) {
t.Run("uses cache when populated", func(t *testing.T) {
calls := 0
app := NewSwitchApp(&mockHAClient{
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
calls++
return nil, nil
},
})
app.cache = []domain.Switch{{EntityID: "switch.fan", State: "on"}}
got, err := app.ListSwitches(context.Background())
if err != nil {
t.Fatalf("ListSwitches() error = %v", err)
}
if calls != 0 {
t.Fatalf("ListStates() calls = %d, want 0", calls)
}
if !reflect.DeepEqual(got, app.cache) {
t.Fatalf("ListSwitches() = %#v, want %#v", got, app.cache)
}
})
t.Run("calls ListStates when cache is nil", func(t *testing.T) {
calls := 0
app := NewSwitchApp(&mockHAClient{
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
calls++
return []*driven.HAState{
{EntityID: "switch.fan", State: "on"},
{EntityID: "sensor.temp", State: "21"},
}, nil
},
})
got, err := app.ListSwitches(context.Background())
if err != nil {
t.Fatalf("ListSwitches() error = %v", err)
}
if calls != 1 {
t.Fatalf("ListStates() calls = %d, want 1", calls)
}
want := []domain.Switch{{EntityID: "switch.fan", State: "on"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ListSwitches() = %#v, want %#v", got, want)
}
})
t.Run("propagates refresh error", func(t *testing.T) {
wantErr := errors.New("list failed")
app := NewSwitchApp(&mockHAClient{
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
return nil, wantErr
},
})
_, err := app.ListSwitches(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("ListSwitches() error = %v, want %v", err, wantErr)
}
})
}
func TestSwitchAppTurnOn(t *testing.T) {
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
callState := &driven.HAState{
EntityID: "switch.fan",
State: "on",
Attributes: map[string]any{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}
fallbackState := &driven.HAState{
EntityID: "switch.fan",
State: "on",
Attributes: map[string]any{"friendly_name": "Fan", "source": "fallback"},
LastChanged: now,
LastUpdated: now,
}
t.Run("happy path", func(t *testing.T) {
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
if svcDomain != "switch" || service != "turn_on" {
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
}
wantPayload := map[string]any{"entity_id": "switch.fan"}
if !reflect.DeepEqual(payload, wantPayload) {
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
}
return []*driven.HAState{callState}, nil
},
})
got, err := app.TurnOn(context.Background(), "switch.fan")
if err != nil {
t.Fatalf("TurnOn() error = %v", err)
}
if !reflect.DeepEqual(got, haStateToDomain(callState)) {
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(callState))
}
})
t.Run("falls back to GetState when service returns empty list", func(t *testing.T) {
getStateCalls := 0
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
return []*driven.HAState{}, nil
},
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
getStateCalls++
if entityID != "switch.fan" {
t.Fatalf("GetState() entityID = %q, want %q", entityID, "switch.fan")
}
return fallbackState, nil
},
})
got, err := app.TurnOn(context.Background(), "switch.fan")
if err != nil {
t.Fatalf("TurnOn() error = %v", err)
}
if getStateCalls != 1 {
t.Fatalf("GetState() calls = %d, want 1", getStateCalls)
}
if !reflect.DeepEqual(got, haStateToDomain(fallbackState)) {
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(fallbackState))
}
})
t.Run("returns CallService error", func(t *testing.T) {
wantErr := errors.New("call failed")
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
return nil, wantErr
},
})
_, err := app.TurnOn(context.Background(), "switch.fan")
if !errors.Is(err, wantErr) {
t.Fatalf("TurnOn() error = %v, want %v", err, wantErr)
}
})
}
func TestSwitchAppTurnOff(t *testing.T) {
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
state := &driven.HAState{
EntityID: "switch.fan",
State: "off",
Attributes: map[string]any{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}
t.Run("happy path", func(t *testing.T) {
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
if svcDomain != "switch" || service != "turn_off" {
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
}
wantPayload := map[string]any{"entity_id": "switch.fan"}
if !reflect.DeepEqual(payload, wantPayload) {
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
}
return []*driven.HAState{state}, nil
},
})
got, err := app.TurnOff(context.Background(), "switch.fan")
if err != nil {
t.Fatalf("TurnOff() error = %v", err)
}
if !reflect.DeepEqual(got, haStateToDomain(state)) {
t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state))
}
})
t.Run("error path", func(t *testing.T) {
wantErr := errors.New("turn off failed")
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
return nil, wantErr
},
})
_, err := app.TurnOff(context.Background(), "switch.fan")
if !errors.Is(err, wantErr) {
t.Fatalf("TurnOff() error = %v, want %v", err, wantErr)
}
})
}
func TestSwitchAppToggle(t *testing.T) {
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
state := &driven.HAState{
EntityID: "switch.fan",
State: "on",
Attributes: map[string]any{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}
t.Run("happy path", func(t *testing.T) {
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
if svcDomain != "switch" || service != "toggle" {
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
}
wantPayload := map[string]any{"entity_id": "switch.fan"}
if !reflect.DeepEqual(payload, wantPayload) {
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
}
return []*driven.HAState{state}, nil
},
})
got, err := app.Toggle(context.Background(), "switch.fan")
if err != nil {
t.Fatalf("Toggle() error = %v", err)
}
if !reflect.DeepEqual(got, haStateToDomain(state)) {
t.Fatalf("Toggle() = %#v, want %#v", got, haStateToDomain(state))
}
})
t.Run("error path", func(t *testing.T) {
wantErr := errors.New("toggle failed")
app := NewSwitchApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
return nil, wantErr
},
})
_, err := app.Toggle(context.Background(), "switch.fan")
if !errors.Is(err, wantErr) {
t.Fatalf("Toggle() error = %v, want %v", err, wantErr)
}
})
}