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" ) type mockHAClient struct { getStateFunc func(ctx context.Context, entityID string) (*driven.HAState, error) listStatesFunc func(ctx context.Context) ([]*driven.HAState, error) callServiceFunc func(ctx context.Context, domain, service string, payload map[string]any) ([]*driven.HAState, error) } func (m *mockHAClient) GetState(ctx context.Context, entityID string) (*driven.HAState, error) { if m.getStateFunc == nil { return nil, nil } return m.getStateFunc(ctx, entityID) } func (m *mockHAClient) ListStates(ctx context.Context) ([]*driven.HAState, error) { if m.listStatesFunc == nil { return nil, nil } return m.listStatesFunc(ctx) } func (m *mockHAClient) CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*driven.HAState, error) { if m.callServiceFunc == nil { return nil, nil } return m.callServiceFunc(ctx, domain, service, payload) } func TestLightAppRefresh(t *testing.T) { t.Run("filters non-light entities and populates cache", func(t *testing.T) { ha := &mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { return []*driven.HAState{ { EntityID: "light.kitchen", State: "on", Attributes: map[string]any{ "friendly_name": "Kitchen", }, }, { EntityID: "switch.fan", State: "off", }, { EntityID: "light.desk", State: "off", Attributes: map[string]any{ "friendly_name": "Desk", "supported_color_modes": []any{"brightness"}, }, }, }, nil }, } app := NewLightApp(ha) if err := app.Refresh(context.Background()); err != nil { t.Fatalf("Refresh() error = %v", err) } got := app.cache want := []domain.Light{ {EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "on"}, { EntityID: "light.desk", FriendlyName: "Desk", State: "off", SupportedColorModes: []domain.ColorMode{domain.ColorModeBrightness}, }, } if !reflect.DeepEqual(got, want) { t.Fatalf("cache = %#v, want %#v", got, want) } }) } func TestLightAppListLights(t *testing.T) { t.Run("uses cache when populated", func(t *testing.T) { calls := 0 app := NewLightApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { calls++ return nil, nil }, }) app.cache = []domain.Light{{EntityID: "light.kitchen", State: "on"}} got, err := app.ListLights(context.Background()) if err != nil { t.Fatalf("ListLights() error = %v", err) } if calls != 0 { t.Fatalf("ListStates() calls = %d, want 0", calls) } if !reflect.DeepEqual(got, app.cache) { t.Fatalf("ListLights() = %#v, want %#v", got, app.cache) } }) t.Run("calls ListStates when cache is nil", func(t *testing.T) { calls := 0 app := NewLightApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { calls++ return []*driven.HAState{ {EntityID: "light.kitchen", State: "on"}, {EntityID: "sensor.temp", State: "21"}, }, nil }, }) got, err := app.ListLights(context.Background()) if err != nil { t.Fatalf("ListLights() error = %v", err) } if calls != 1 { t.Fatalf("ListStates() calls = %d, want 1", calls) } want := []domain.Light{{EntityID: "light.kitchen", State: "on"}} if !reflect.DeepEqual(got, want) { t.Fatalf("ListLights() = %#v, want %#v", got, want) } }) t.Run("propagates refresh error", func(t *testing.T) { wantErr := errors.New("list failed") app := NewLightApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.ListLights(context.Background()) if !errors.Is(err, wantErr) { t.Fatalf("ListLights() error = %v, want %v", err, wantErr) } }) } func TestLightAppTurnOn(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) brightness := uint32(75) colorTemp := uint32(3200) transition := uint32(4) callState := &driven.HAState{ EntityID: "light.kitchen", State: "on", Attributes: map[string]any{"friendly_name": "Kitchen", "brightness_pct": 75}, LastChanged: now, LastUpdated: now, } fallbackState := &driven.HAState{ EntityID: "light.kitchen", State: "on", Attributes: map[string]any{"friendly_name": "Kitchen", "source": "fallback"}, LastChanged: now, LastUpdated: now, } tests := []struct { name string params domain.TurnOnParams callResult []*driven.HAState callErr error getState *driven.HAState getStateErr error wantPayload map[string]any wantState *domain.EntityState }{ { name: "all optional params present", params: domain.TurnOnParams{ EntityID: "light.kitchen", BrightnessPct: &brightness, ColorTempKelvin: &colorTemp, RGBColor: &domain.RGBColor{R: 1, G: 2, B: 3}, Transition: &transition, }, callResult: []*driven.HAState{callState}, wantPayload: map[string]any{ "entity_id": "light.kitchen", "brightness_pct": brightness, "color_temp_kelvin": colorTemp, "rgb_color": []uint8{1, 2, 3}, "transition": transition, }, wantState: haStateToDomain(callState), }, { name: "no optional params", params: domain.TurnOnParams{ EntityID: "light.kitchen", }, callResult: []*driven.HAState{callState}, wantPayload: map[string]any{ "entity_id": "light.kitchen", }, wantState: haStateToDomain(callState), }, { name: "falls back to GetState when service returns empty list", params: domain.TurnOnParams{ EntityID: "light.kitchen", }, callResult: []*driven.HAState{}, getState: fallbackState, wantPayload: map[string]any{ "entity_id": "light.kitchen", }, wantState: haStateToDomain(fallbackState), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { getStateCalls := 0 app := NewLightApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "light" || service != "turn_on" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } if !reflect.DeepEqual(payload, tt.wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, tt.wantPayload) } return tt.callResult, tt.callErr }, getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { getStateCalls++ if entityID != "light.kitchen" { t.Fatalf("GetState() entityID = %q, want %q", entityID, "light.kitchen") } return tt.getState, tt.getStateErr }, }) got, err := app.TurnOn(context.Background(), tt.params) if err != nil { t.Fatalf("TurnOn() error = %v", err) } if !reflect.DeepEqual(got, tt.wantState) { t.Fatalf("TurnOn() = %#v, want %#v", got, tt.wantState) } wantGetStateCalls := 0 if len(tt.callResult) == 0 { wantGetStateCalls = 1 } if getStateCalls != wantGetStateCalls { t.Fatalf("GetState() calls = %d, want %d", getStateCalls, wantGetStateCalls) } }) } t.Run("returns CallService error", func(t *testing.T) { wantErr := errors.New("call failed") app := NewLightApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.TurnOn(context.Background(), domain.TurnOnParams{EntityID: "light.kitchen"}) if !errors.Is(err, wantErr) { t.Fatalf("TurnOn() error = %v, want %v", err, wantErr) } }) } func TestLightAppTurnOff(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) transition := uint32(3) state := &driven.HAState{ EntityID: "light.kitchen", State: "off", Attributes: map[string]any{"friendly_name": "Kitchen"}, LastChanged: now, LastUpdated: now, } tests := []struct { name string params domain.TurnOffParams callErr error wantPayload map[string]any wantErr error }{ { name: "with transition", params: domain.TurnOffParams{ EntityID: "light.kitchen", Transition: &transition, }, wantPayload: map[string]any{ "entity_id": "light.kitchen", "transition": transition, }, }, { name: "without transition", params: domain.TurnOffParams{ EntityID: "light.kitchen", }, wantPayload: map[string]any{ "entity_id": "light.kitchen", }, }, { name: "error path", params: domain.TurnOffParams{ EntityID: "light.kitchen", }, callErr: errors.New("turn off failed"), wantPayload: map[string]any{ "entity_id": "light.kitchen", }, wantErr: errors.New("turn off failed"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { app := NewLightApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "light" || service != "turn_off" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } if !reflect.DeepEqual(payload, tt.wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, tt.wantPayload) } if tt.callErr != nil { return nil, tt.callErr } return []*driven.HAState{state}, nil }, }) got, err := app.TurnOff(context.Background(), tt.params) if tt.wantErr != nil { if err == nil || err.Error() != tt.wantErr.Error() { t.Fatalf("TurnOff() error = %v, want %v", err, tt.wantErr) } return } if err != nil { t.Fatalf("TurnOff() error = %v", err) } if !reflect.DeepEqual(got, haStateToDomain(state)) { t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state)) } }) } } func TestLightAppToggle(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) state := &driven.HAState{ EntityID: "light.kitchen", State: "on", Attributes: map[string]any{"friendly_name": "Kitchen"}, LastChanged: now, LastUpdated: now, } t.Run("happy path", func(t *testing.T) { app := NewLightApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "light" || service != "toggle" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } wantPayload := map[string]any{"entity_id": "light.kitchen"} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{state}, nil }, }) got, err := app.Toggle(context.Background(), "light.kitchen") 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 := NewLightApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.Toggle(context.Background(), "light.kitchen") if !errors.Is(err, wantErr) { t.Fatalf("Toggle() error = %v, want %v", err, wantErr) } }) }