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

286 lines
7.9 KiB
Go

package grpc
import (
"context"
"errors"
"net"
"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 mockSwitchService struct {
turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
toggleFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
listSwitchesFunc func(ctx context.Context) ([]domain.Switch, error)
refreshFunc func(ctx context.Context) error
}
func (m *mockSwitchService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.turnOnFunc == nil {
return nil, nil
}
return m.turnOnFunc(ctx, id)
}
func (m *mockSwitchService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.turnOffFunc == nil {
return nil, nil
}
return m.turnOffFunc(ctx, id)
}
func (m *mockSwitchService) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.toggleFunc == nil {
return nil, nil
}
return m.toggleFunc(ctx, id)
}
func (m *mockSwitchService) ListSwitches(ctx context.Context) ([]domain.Switch, error) {
if m.listSwitchesFunc == nil {
return nil, nil
}
return m.listSwitchesFunc(ctx)
}
func (m *mockSwitchService) Refresh(ctx context.Context) error {
if m.refreshFunc == nil {
return nil
}
return m.refreshFunc(ctx)
}
func TestSwitchGRPCTurnOn(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},
{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 gotID domain.EntityID
conn := newSwitchTestClientConn(t, &mockSwitchService{
turnOnFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
gotID = id
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{
EntityID: "switch.fan",
State: "on",
Attributes: map[string]string{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}, nil
},
})
client := hav1.NewSwitchServiceClient(conn)
resp, err := client.TurnOn(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"})
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 != "switch.fan" {
t.Fatalf("TurnOn id = %q, want %q", gotID, "switch.fan")
}
if resp.GetState().GetEntityId() != "switch.fan" || resp.GetState().GetState() != "on" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestSwitchGRPCTurnOff(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 gotID domain.EntityID
conn := newSwitchTestClientConn(t, &mockSwitchService{
turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
gotID = id
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{
EntityID: "switch.fan",
State: "off",
Attributes: map[string]string{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}, nil
},
})
client := hav1.NewSwitchServiceClient(conn)
resp, err := client.TurnOff(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"})
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 != "switch.fan" {
t.Fatalf("TurnOff id = %q, want %q", gotID, "switch.fan")
}
if resp.GetState().GetState() != "off" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestSwitchGRPCToggle(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 := newSwitchTestClientConn(t, &mockSwitchService{
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: "switch.fan",
State: "on",
Attributes: map[string]string{"friendly_name": "Fan"},
LastChanged: now,
LastUpdated: now,
}, nil
},
})
client := hav1.NewSwitchServiceClient(conn)
resp, err := client.Toggle(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"})
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 != "switch.fan" {
t.Fatalf("Toggle id = %q, want %q", gotID, "switch.fan")
}
if resp.GetState().GetEntityId() != "switch.fan" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestSwitchGRPCListSwitches(t *testing.T) {
tests := []struct {
name string
err error
wantCode codes.Code
}{
{name: "happy path with multiple switches", 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 := newSwitchTestClientConn(t, &mockSwitchService{
listSwitchesFunc: func(ctx context.Context) ([]domain.Switch, error) {
if tt.err != nil {
return nil, tt.err
}
return []domain.Switch{
{EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "switch"},
{EntityID: "switch.heater", FriendlyName: "Heater", State: "off"},
}, nil
},
})
client := hav1.NewSwitchServiceClient(conn)
resp, err := client.ListSwitches(context.Background(), &hav1.ListSwitchesRequest{})
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.GetSwitches()) != 2 {
t.Fatalf("len(switches) = %d, want 2", len(resp.GetSwitches()))
}
if resp.GetSwitches()[0].GetEntityId() != "switch.fan" || resp.GetSwitches()[1].GetEntityId() != "switch.heater" {
t.Fatalf("switches = %#v", resp.GetSwitches())
}
})
}
}
func newSwitchTestClientConn(t *testing.T, svc *mockSwitchService) *grpc.ClientConn {
t.Helper()
lis := bufconn.Listen(testBufSize)
server := grpc.NewServer()
hav1.RegisterSwitchServiceServer(server, NewSwitchGRPC(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
}