From fb62076fbc918b0f02cb9b5041d47b3710e2f8e2 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 9 Apr 2026 23:12:04 +0900 Subject: [PATCH] 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. --- discord-bot/internal/app/command_test.go | 483 ++++++++++++++++++ .../adapters/primary/grpc/entity_test.go | 185 +++++++ .../adapters/primary/grpc/interceptor_test.go | 78 +++ .../adapters/primary/grpc/light_test.go | 322 ++++++++++++ ha-gateway/internal/app/entity_test.go | 172 +++++++ ha-gateway/internal/app/light_test.go | 412 +++++++++++++++ 6 files changed, 1652 insertions(+) create mode 100644 discord-bot/internal/app/command_test.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/entity_test.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/interceptor_test.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/light_test.go create mode 100644 ha-gateway/internal/app/entity_test.go create mode 100644 ha-gateway/internal/app/light_test.go diff --git a/discord-bot/internal/app/command_test.go b/discord-bot/internal/app/command_test.go new file mode 100644 index 0000000..8b72a87 --- /dev/null +++ b/discord-bot/internal/app/command_test.go @@ -0,0 +1,483 @@ +package app + +import ( + "context" + "errors" + "reflect" + "testing" + + "gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven" +) + +type mockHAGateway struct { + listLightsFunc func(ctx context.Context) ([]driven.Light, error) + listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error) + turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error + turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error + toggleLightFunc func(ctx context.Context, entityID string) error +} + +func (m *mockHAGateway) ListLights(ctx context.Context) ([]driven.Light, error) { + if m.listLightsFunc == nil { + return nil, nil + } + return m.listLightsFunc(ctx) +} + +func (m *mockHAGateway) ListSwitches(ctx context.Context) ([]driven.Switch, error) { + if m.listSwitchesFunc == nil { + return nil, nil + } + return m.listSwitchesFunc(ctx) +} + +func (m *mockHAGateway) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error { + if m.turnOnLightFunc == nil { + return nil + } + return m.turnOnLightFunc(ctx, entityID, brightnessPct, colorTempKelvin) +} + +func (m *mockHAGateway) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error { + if m.turnOffLightFunc == nil { + return nil + } + return m.turnOffLightFunc(ctx, entityID, transition) +} + +func (m *mockHAGateway) ToggleLight(ctx context.Context, entityID string) error { + if m.toggleLightFunc == nil { + return nil + } + return m.toggleLightFunc(ctx, entityID) +} + +func TestCommandAppHandleLightList(t *testing.T) { + tests := []struct { + name string + lights []driven.Light + want string + }{ + { + name: "empty list", + want: "No lights found.", + }, + { + name: "single light with full data", + lights: []driven.Light{ + { + EntityID: "light.kitchen", + FriendlyName: "Kitchen", + State: "on", + SupportedColorModes: []string{"brightness", "color_temp"}, + MinColorTempKelvin: 2700, + MaxColorTempKelvin: 6500, + IsHueGroup: true, + }, + }, + want: "```text\n🟢 Kitchen on brightness,color_temp 2700-6500K hue-group\n```", + }, + { + name: "empty friendly name uses entity id and off emoji", + lights: []driven.Light{ + { + EntityID: "light.desk", + State: "off", + }, + }, + want: "```text\n🔴 light.desk off -\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewCommandApp(&mockHAGateway{ + listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { + return tt.lights, nil + }, + }) + + got, err := app.HandleLightList(context.Background()) + if err != nil { + t.Fatalf("HandleLightList() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleLightList() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppHandleLightOn(t *testing.T) { + brightness := uint32(55) + colorTemp := uint32(3200) + + tests := []struct { + name string + lights []driven.Light + entityID string + brightnessPct *uint32 + colorTempKelvin *uint32 + turnOnErr error + listErr error + want string + wantErr string + }{ + { + name: "no optional params", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + entityID: "light.kitchen", + want: "Turned on `Kitchen`.", + }, + { + name: "brightness only", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + entityID: "light.kitchen", + brightnessPct: &brightness, + want: "Turned on `Kitchen` (brightness 55%).", + }, + { + name: "color temp only", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + entityID: "light.kitchen", + colorTempKelvin: &colorTemp, + want: "Turned on `Kitchen` (3200K).", + }, + { + name: "both params", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + entityID: "light.kitchen", + brightnessPct: &brightness, + colorTempKelvin: &colorTemp, + want: "Turned on `Kitchen` (brightness 55%, 3200K).", + }, + { + name: "light not found falls back to entity id", + lights: []driven.Light{{EntityID: "light.other", FriendlyName: "Other"}}, + entityID: "light.kitchen", + want: "Turned on `light.kitchen`.", + }, + { + name: "TurnOnLight error", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + entityID: "light.kitchen", + turnOnErr: errors.New("boom"), + wantErr: "handle light on: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotEntityID string + var gotBrightness *uint32 + var gotColorTemp *uint32 + + app := NewCommandApp(&mockHAGateway{ + listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.lights, nil + }, + turnOnLightFunc: func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error { + gotEntityID = entityID + gotBrightness = brightnessPct + gotColorTemp = colorTempKelvin + return tt.turnOnErr + }, + }) + + got, err := app.HandleLightOn(context.Background(), tt.entityID, tt.brightnessPct, tt.colorTempKelvin) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleLightOn() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleLightOn() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleLightOn() = %q, want %q", got, tt.want) + } + if gotEntityID != tt.entityID { + t.Fatalf("TurnOnLight entityID = %q, want %q", gotEntityID, tt.entityID) + } + if !reflect.DeepEqual(gotBrightness, tt.brightnessPct) || !reflect.DeepEqual(gotColorTemp, tt.colorTempKelvin) { + t.Fatalf("TurnOnLight params = (%v, %v), want (%v, %v)", gotBrightness, gotColorTemp, tt.brightnessPct, tt.colorTempKelvin) + } + }) + } +} + +func TestCommandAppHandleLightOff(t *testing.T) { + transition := uint32(4) + + tests := []struct { + name string + entityID string + lights []driven.Light + transition *uint32 + turnOffErr error + want string + wantErr string + }{ + { + name: "no transition", + entityID: "light.kitchen", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + want: "Turned off `Kitchen`.", + }, + { + name: "with transition", + entityID: "light.kitchen", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + transition: &transition, + want: "Turned off `Kitchen` with 4s transition.", + }, + { + name: "error", + entityID: "light.kitchen", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + turnOffErr: errors.New("boom"), + wantErr: "handle light off: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotTransition *uint32 + app := NewCommandApp(&mockHAGateway{ + listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { + return tt.lights, nil + }, + turnOffLightFunc: func(ctx context.Context, entityID string, transition *uint32) error { + gotTransition = transition + return tt.turnOffErr + }, + }) + + got, err := app.HandleLightOff(context.Background(), tt.entityID, tt.transition) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleLightOff() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleLightOff() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleLightOff() = %q, want %q", got, tt.want) + } + if !reflect.DeepEqual(gotTransition, tt.transition) { + t.Fatalf("TurnOffLight transition = %v, want %v", gotTransition, tt.transition) + } + }) + } +} + +func TestCommandAppHandleLightToggle(t *testing.T) { + tests := []struct { + name string + lights []driven.Light + listErr error + toggleErr error + want string + wantErr string + }{ + { + name: "happy path", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + want: "Toggled `Kitchen`.", + }, + { + name: "lookup error", + listErr: errors.New("lookup failed"), + wantErr: "lookup light name: list lights: lookup failed", + }, + { + name: "toggle error", + lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}}, + toggleErr: errors.New("boom"), + wantErr: "handle light toggle: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewCommandApp(&mockHAGateway{ + listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.lights, nil + }, + toggleLightFunc: func(ctx context.Context, entityID string) error { + return tt.toggleErr + }, + }) + + got, err := app.HandleLightToggle(context.Background(), "light.kitchen") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleLightToggle() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleLightToggle() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleLightToggle() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppHandleSwitchList(t *testing.T) { + tests := []struct { + name string + switches []driven.Switch + want string + }{ + { + name: "empty", + want: "No switches found.", + }, + { + name: "with switches", + switches: []driven.Switch{ + {EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "outlet"}, + {EntityID: "switch.pump", State: "off"}, + }, + want: "```text\n🟢 Fan on outlet\n🔴 switch.pump off switch\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewCommandApp(&mockHAGateway{ + listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { + return tt.switches, nil + }, + }) + + got, err := app.HandleSwitchList(context.Background()) + if err != nil { + t.Fatalf("HandleSwitchList() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleSwitchList() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppAutocompleteLights(t *testing.T) { + tests := []struct { + name string + lights []driven.Light + listErr error + want []Choice + wantErr string + }{ + { + name: "friendly name and fallback", + lights: []driven.Light{ + {EntityID: "light.kitchen", FriendlyName: "Kitchen"}, + {EntityID: "light.desk"}, + }, + want: []Choice{ + {Label: "Kitchen", Value: "light.kitchen"}, + {Label: "light.desk", Value: "light.desk"}, + }, + }, + { + name: "ListLights error", + listErr: errors.New("boom"), + wantErr: "autocomplete lights: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewCommandApp(&mockHAGateway{ + listLightsFunc: func(ctx context.Context) ([]driven.Light, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.lights, nil + }, + }) + + got, err := app.AutocompleteLights(context.Background()) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("AutocompleteLights() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("AutocompleteLights() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AutocompleteLights() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestCommandAppAutocompleteSwitches(t *testing.T) { + tests := []struct { + name string + switches []driven.Switch + listErr error + want []Choice + wantErr string + }{ + { + name: "friendly name and fallback", + switches: []driven.Switch{ + {EntityID: "switch.fan", FriendlyName: "Fan"}, + {EntityID: "switch.pump"}, + }, + want: []Choice{ + {Label: "Fan", Value: "switch.fan"}, + {Label: "switch.pump", Value: "switch.pump"}, + }, + }, + { + name: "ListSwitches error", + listErr: errors.New("boom"), + wantErr: "autocomplete switches: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewCommandApp(&mockHAGateway{ + listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.switches, nil + }, + }) + + got, err := app.AutocompleteSwitches(context.Background()) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("AutocompleteSwitches() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("AutocompleteSwitches() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AutocompleteSwitches() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/ha-gateway/internal/adapters/primary/grpc/entity_test.go b/ha-gateway/internal/adapters/primary/grpc/entity_test.go new file mode 100644 index 0000000..e920f08 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/entity_test.go @@ -0,0 +1,185 @@ +package grpc + +import ( + "context" + "errors" + "net" + "reflect" + "testing" + "time" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type mockEntityService struct { + getStateFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + listStatesFunc func(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) +} + +func (m *mockEntityService) GetState(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.getStateFunc == nil { + return nil, nil + } + return m.getStateFunc(ctx, id) +} + +func (m *mockEntityService) ListStates(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) { + if m.listStatesFunc == nil { + return nil, nil + } + return m.listStatesFunc(ctx, ids, domainFilter) +} + +func TestEntityGRPCGetState(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "not found maps to codes.NotFound", err: ErrNotFound, wantCode: codes.NotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + conn := newEntityTestClientConn(t, &mockEntityService{ + getStateFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]string{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewEntityServiceClient(conn) + + resp, err := client.GetState(context.Background(), &hav1.GetStateRequest{EntityId: "light.kitchen"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + if gotID != "light.kitchen" { + t.Fatalf("GetState id = %q, want %q", gotID, "light.kitchen") + } + if resp.GetState().GetEntityId() != "light.kitchen" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestEntityGRPCListStates(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotIDs []domain.EntityID + var gotDomain string + conn := newEntityTestClientConn(t, &mockEntityService{ + listStatesFunc: func(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) { + gotIDs = append([]domain.EntityID(nil), ids...) + gotDomain = domainFilter + if tt.err != nil { + return nil, tt.err + } + return []*domain.EntityState{ + { + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]string{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, + { + EntityID: "light.desk", + State: "off", + Attributes: map[string]string{"friendly_name": "Desk"}, + LastChanged: now, + LastUpdated: now, + }, + }, nil + }, + }) + client := hav1.NewEntityServiceClient(conn) + + resp, err := client.ListStates(context.Background(), &hav1.ListStatesRequest{ + EntityIds: []string{"light.kitchen", "light.desk"}, + Domain: "light", + }) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + wantIDs := []domain.EntityID{"light.kitchen", "light.desk"} + if !reflect.DeepEqual(gotIDs, wantIDs) { + t.Fatalf("ListStates ids = %#v, want %#v", gotIDs, wantIDs) + } + if gotDomain != "light" { + t.Fatalf("ListStates domain = %q, want %q", gotDomain, "light") + } + if len(resp.GetStates()) != 2 { + t.Fatalf("len(states) = %d, want 2", len(resp.GetStates())) + } + }) + } +} + +func newEntityTestClientConn(t *testing.T, svc *mockEntityService) *grpc.ClientConn { + t.Helper() + + lis := bufconn.Listen(testBufSize) + server := grpc.NewServer() + hav1.RegisterEntityServiceServer(server, NewEntityGRPC(svc)) + go func() { + _ = server.Serve(lis) + }() + t.Cleanup(func() { + server.Stop() + _ = lis.Close() + }) + + conn, err := grpc.DialContext( + context.Background(), + "bufnet", + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.DialContext() error = %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + return conn +} diff --git a/ha-gateway/internal/adapters/primary/grpc/interceptor_test.go b/ha-gateway/internal/adapters/primary/grpc/interceptor_test.go new file mode 100644 index 0000000..229ece7 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/interceptor_test.go @@ -0,0 +1,78 @@ +package grpc + +import ( + "context" + "log/slog" + "sync" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type recordingHandler struct { + mu sync.Mutex + levels []slog.Level +} + +func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { + return true +} + +func (h *recordingHandler) Handle(ctx context.Context, record slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.levels = append(h.levels, record.Level) + return nil +} + +func (h *recordingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *recordingHandler) WithGroup(name string) slog.Handler { + return h +} + +func TestLoggingUnaryInterceptor(t *testing.T) { + tests := []struct { + name string + handlerErr error + wantLevel slog.Level + }{ + {name: "nil error logs info", wantLevel: slog.LevelInfo}, + {name: "not found logs warn", handlerErr: status.Error(codes.NotFound, "missing"), wantLevel: slog.LevelWarn}, + {name: "internal logs error", handlerErr: status.Error(codes.Internal, "boom"), wantLevel: slog.LevelError}, + {name: "unimplemented logs warn", handlerErr: status.Error(codes.Unimplemented, "stub"), wantLevel: slog.LevelWarn}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &recordingHandler{} + interceptor := LoggingUnaryInterceptor(slog.New(h)) + + _, err := interceptor( + context.Background(), + "struct{}{}", + &grpc.UnaryServerInfo{FullMethod: "/ha.v1.LightService/TurnOn"}, + func(ctx context.Context, req any) (any, error) { + return "ok", tt.handlerErr + }, + ) + if status.Code(err) != status.Code(tt.handlerErr) { + t.Fatalf("handler error = %v, want %v", err, tt.handlerErr) + } + + h.mu.Lock() + defer h.mu.Unlock() + if len(h.levels) == 0 { + t.Fatal("no log records captured") + } + got := h.levels[len(h.levels)-1] + if got != tt.wantLevel { + t.Fatalf("log level = %v, want %v", got, tt.wantLevel) + } + }) + } +} diff --git a/ha-gateway/internal/adapters/primary/grpc/light_test.go b/ha-gateway/internal/adapters/primary/grpc/light_test.go new file mode 100644 index 0000000..e90ae29 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/light_test.go @@ -0,0 +1,322 @@ +package grpc + +import ( + "context" + "errors" + "net" + "reflect" + "testing" + "time" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +const testBufSize = 1024 * 1024 + +type mockLightService struct { + turnOnFunc func(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) + turnOffFunc func(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) + toggleFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + listLightsFunc func(ctx context.Context) ([]domain.Light, error) + refreshFunc func(ctx context.Context) error +} + +func (m *mockLightService) TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) { + if m.turnOnFunc == nil { + return nil, nil + } + return m.turnOnFunc(ctx, p) +} + +func (m *mockLightService) TurnOff(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) { + if m.turnOffFunc == nil { + return nil, nil + } + return m.turnOffFunc(ctx, p) +} + +func (m *mockLightService) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.toggleFunc == nil { + return nil, nil + } + return m.toggleFunc(ctx, id) +} + +func (m *mockLightService) ListLights(ctx context.Context) ([]domain.Light, error) { + if m.listLightsFunc == nil { + return nil, nil + } + return m.listLightsFunc(ctx) +} + +func (m *mockLightService) Refresh(ctx context.Context) error { + if m.refreshFunc == nil { + return nil + } + return m.refreshFunc(ctx) +} + +func TestLightGRPCTurnOn(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + brightness := uint32(55) + colorTemp := uint32(3200) + transition := uint32(2) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "not found maps to codes.NotFound", err: ErrNotFound, wantCode: codes.NotFound}, + {name: "generic error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotParams domain.TurnOnParams + conn := newLightTestClientConn(t, &mockLightService{ + turnOnFunc: func(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) { + gotParams = p + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]string{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewLightServiceClient(conn) + + resp, err := client.TurnOn(context.Background(), &hav1.TurnOnRequest{ + EntityId: "light.kitchen", + BrightnessPct: &brightness, + ColorTempKelvin: &colorTemp, + RgbColor: &hav1.RGBColor{R: 1, G: 2, B: 3}, + Transition: &transition, + }) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + wantParams := domain.TurnOnParams{ + EntityID: "light.kitchen", + BrightnessPct: &brightness, + ColorTempKelvin: &colorTemp, + RGBColor: &domain.RGBColor{R: 1, G: 2, B: 3}, + Transition: &transition, + } + if !reflect.DeepEqual(gotParams, wantParams) { + t.Fatalf("TurnOn params = %#v, want %#v", gotParams, wantParams) + } + if resp.GetState().GetEntityId() != "light.kitchen" || resp.GetState().GetState() != "on" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestLightGRPCTurnOff(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + transition := uint32(3) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotParams domain.TurnOffParams + conn := newLightTestClientConn(t, &mockLightService{ + turnOffFunc: func(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) { + gotParams = p + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "light.kitchen", + State: "off", + Attributes: map[string]string{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewLightServiceClient(conn) + + resp, err := client.TurnOff(context.Background(), &hav1.TurnOffRequest{ + EntityId: "light.kitchen", + Transition: &transition, + }) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + wantParams := domain.TurnOffParams{EntityID: "light.kitchen", Transition: &transition} + if !reflect.DeepEqual(gotParams, wantParams) { + t.Fatalf("TurnOff params = %#v, want %#v", gotParams, wantParams) + } + if resp.GetState().GetState() != "off" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestLightGRPCToggle(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "not implemented maps to codes.Unimplemented", err: ErrNotImplemented, wantCode: codes.Unimplemented}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + conn := newLightTestClientConn(t, &mockLightService{ + toggleFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]string{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewLightServiceClient(conn) + + resp, err := client.Toggle(context.Background(), &hav1.ToggleRequest{EntityId: "light.kitchen"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + if gotID != "light.kitchen" { + t.Fatalf("Toggle id = %q, want %q", gotID, "light.kitchen") + } + if resp.GetState().GetEntityId() != "light.kitchen" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestLightGRPCListLights(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path with multiple lights", wantCode: codes.OK}, + {name: "error path", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := newLightTestClientConn(t, &mockLightService{ + listLightsFunc: func(ctx context.Context) ([]domain.Light, error) { + if tt.err != nil { + return nil, tt.err + } + return []domain.Light{ + { + EntityID: "light.kitchen", + FriendlyName: "Kitchen", + State: "on", + SupportedColorModes: []domain.ColorMode{domain.ColorModeBrightness}, + MinColorTempKelvin: 2700, + MaxColorTempKelvin: 6500, + IsHueGroup: true, + EffectList: []string{"rainbow"}, + }, + { + EntityID: "light.desk", + FriendlyName: "Desk", + State: "off", + }, + }, nil + }, + }) + client := hav1.NewLightServiceClient(conn) + + resp, err := client.ListLights(context.Background(), &hav1.ListLightsRequest{}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + if len(resp.GetLights()) != 2 { + t.Fatalf("len(lights) = %d, want 2", len(resp.GetLights())) + } + if resp.GetLights()[0].GetEntityId() != "light.kitchen" || resp.GetLights()[1].GetEntityId() != "light.desk" { + t.Fatalf("lights = %#v", resp.GetLights()) + } + }) + } +} + +func newLightTestClientConn(t *testing.T, svc *mockLightService) *grpc.ClientConn { + t.Helper() + + lis := bufconn.Listen(testBufSize) + server := grpc.NewServer() + hav1.RegisterLightServiceServer(server, NewLightGRPC(svc)) + go func() { + _ = server.Serve(lis) + }() + t.Cleanup(func() { + server.Stop() + _ = lis.Close() + }) + + conn, err := grpc.DialContext( + context.Background(), + "bufnet", + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.DialContext() error = %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + return conn +} diff --git a/ha-gateway/internal/app/entity_test.go b/ha-gateway/internal/app/entity_test.go new file mode 100644 index 0000000..e1074a8 --- /dev/null +++ b/ha-gateway/internal/app/entity_test.go @@ -0,0 +1,172 @@ +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 TestEntityAppGetState(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + + t.Run("happy path maps attributes to strings", func(t *testing.T) { + app := NewEntityApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + if entityID != "light.kitchen" { + t.Fatalf("GetState() entityID = %q, want %q", entityID, "light.kitchen") + } + return &driven.HAState{ + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]any{ + "friendly_name": "Kitchen", + "brightness": 42, + "available": true, + }, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + + got, err := app.GetState(context.Background(), "light.kitchen") + if err != nil { + t.Fatalf("GetState() error = %v", err) + } + want := &domain.EntityState{ + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]string{ + "friendly_name": "Kitchen", + "brightness": "42", + "available": "true", + }, + LastChanged: now, + LastUpdated: now, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("GetState() = %#v, want %#v", got, want) + } + }) + + t.Run("propagates ha.GetState error", func(t *testing.T) { + wantErr := errors.New("missing") + app := NewEntityApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.GetState(context.Background(), "light.kitchen") + if !errors.Is(err, wantErr) { + t.Fatalf("GetState() error = %v, want %v", err, wantErr) + } + }) +} + +func TestEntityAppListStates(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + allStates := []*driven.HAState{ + { + EntityID: "light.kitchen", + State: "on", + Attributes: map[string]any{"friendly_name": "Kitchen"}, + LastChanged: now, + LastUpdated: now, + }, + { + EntityID: "switch.fan", + State: "off", + Attributes: map[string]any{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + }, + { + EntityID: "sensor.temp", + State: "21", + Attributes: map[string]any{"unit": "C"}, + LastChanged: now, + LastUpdated: now, + }, + } + + tests := []struct { + name string + ids []domain.EntityID + domainFilter string + want []*domain.EntityState + }{ + { + name: "no filters", + want: []*domain.EntityState{ + haStateToDomain(allStates[0]), + haStateToDomain(allStates[1]), + haStateToDomain(allStates[2]), + }, + }, + { + name: "id filter only", + ids: []domain.EntityID{"switch.fan"}, + want: []*domain.EntityState{ + haStateToDomain(allStates[1]), + }, + }, + { + name: "domain filter only", + domainFilter: "light", + want: []*domain.EntityState{ + haStateToDomain(allStates[0]), + }, + }, + { + name: "both filters", + ids: []domain.EntityID{"light.kitchen", "sensor.temp"}, + domainFilter: "light", + want: []*domain.EntityState{ + haStateToDomain(allStates[0]), + }, + }, + { + name: "id not found returns empty", + ids: []domain.EntityID{"light.missing"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewEntityApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return allStates, nil + }, + }) + + got, err := app.ListStates(context.Background(), tt.ids, tt.domainFilter) + if err != nil { + t.Fatalf("ListStates() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ListStates() = %#v, want %#v", got, tt.want) + } + }) + } + + t.Run("propagates ha.ListStates error", func(t *testing.T) { + wantErr := errors.New("list failed") + app := NewEntityApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.ListStates(context.Background(), nil, "") + if !errors.Is(err, wantErr) { + t.Fatalf("ListStates() error = %v, want %v", err, wantErr) + } + }) +} diff --git a/ha-gateway/internal/app/light_test.go b/ha-gateway/internal/app/light_test.go new file mode 100644 index 0000000..ac5cd0b --- /dev/null +++ b/ha-gateway/internal/app/light_test.go @@ -0,0 +1,412 @@ +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) + } + }) +}