Nik Afiq fb62076fbc
All checks were successful
CI / test (push) Successful in 5s
CI / build-ha-gateway (push) Successful in 44s
CI / build-discord-bot (push) Successful in 46s
Add gRPC tests for entity and light services
- Implement tests for the Entity gRPC service, covering GetState and ListStates methods.
- Create tests for the Light gRPC service, including TurnOn, TurnOff, Toggle, and ListLights methods.
- Introduce mock service implementations to simulate behavior and validate interactions.
- Add logging interceptor tests to ensure proper logging levels based on handler errors.
- Develop application layer tests for entity and light functionalities, ensuring correct state management and error propagation.
2026-04-09 23:12:04 +09:00

413 lines
11 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"
)
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)
}
})
}