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.
This commit is contained in:
parent
c581e79434
commit
fb62076fbc
483
discord-bot/internal/app/command_test.go
Normal file
483
discord-bot/internal/app/command_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
185
ha-gateway/internal/adapters/primary/grpc/entity_test.go
Normal file
185
ha-gateway/internal/adapters/primary/grpc/entity_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
322
ha-gateway/internal/adapters/primary/grpc/light_test.go
Normal file
322
ha-gateway/internal/adapters/primary/grpc/light_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
172
ha-gateway/internal/app/entity_test.go
Normal file
172
ha-gateway/internal/app/entity_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
412
ha-gateway/internal/app/light_test.go
Normal file
412
ha-gateway/internal/app/light_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user