Nik Afiq b327150d45
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
feat: implement ClimateApp for managing HVAC entities
- Added ClimateApp to handle climate entity operations including turning on/off, adjusting temperature, and setting HVAC modes.
- Implemented caching mechanism for climate entities to optimize state retrieval.
- Created domain model for Climate with attributes such as current temperature, target temperature, and HVAC modes.
- Developed unit tests for ClimateApp to ensure functionality and correctness.

feat: add RemoteApp for SwitchBot commands

- Introduced RemoteApp to facilitate sending commands to SwitchBot devices.
- Implemented SendCommand method for executing device commands without state management.

chore: update configuration for SwitchBot integration

- Added SwitchBotToken and SwitchBotSecret to configuration for enabling SwitchBot Cloud commands.

feat: define gRPC services for Climate and Remote operations

- Created climate.proto and remote.proto files to define gRPC services for climate management and remote command execution.
- Implemented corresponding request and response message structures for gRPC interactions.
2026-07-23 13:49:54 +09:00

380 lines
12 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 mockClimateService struct {
turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error)
listClimatesFunc func(ctx context.Context) ([]domain.Climate, error)
refreshFunc func(ctx context.Context) error
}
func (m *mockClimateService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.turnOnFunc == nil {
return nil, nil
}
return m.turnOnFunc(ctx, id)
}
func (m *mockClimateService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.turnOffFunc == nil {
return nil, nil
}
return m.turnOffFunc(ctx, id)
}
func (m *mockClimateService) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.increaseTemperatureFunc == nil {
return nil, nil
}
return m.increaseTemperatureFunc(ctx, id)
}
func (m *mockClimateService) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if m.decreaseTemperatureFunc == nil {
return nil, nil
}
return m.decreaseTemperatureFunc(ctx, id)
}
func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
if m.setHVACModeFunc == nil {
return nil, nil
}
return m.setHVACModeFunc(ctx, id, hvacMode)
}
func (m *mockClimateService) ListClimates(ctx context.Context) ([]domain.Climate, error) {
if m.listClimatesFunc == nil {
return nil, nil
}
return m.listClimatesFunc(ctx)
}
func (m *mockClimateService) Refresh(ctx context.Context) error {
if m.refreshFunc == nil {
return nil
}
return m.refreshFunc(ctx)
}
func TestClimateGRPCTurnOn(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 := newClimateTestClientConn(t, &mockClimateService{
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: "climate.air_conditioner",
State: "cool",
Attributes: map[string]string{"friendly_name": "Air Conditioner"},
LastChanged: now,
LastUpdated: now,
}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.TurnOn(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
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 != "climate.air_conditioner" {
t.Fatalf("TurnOn id = %q, want %q", gotID, "climate.air_conditioner")
}
if resp.GetState().GetEntityId() != "climate.air_conditioner" || resp.GetState().GetState() != "cool" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCTurnOff(t *testing.T) {
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) {
conn := newClimateTestClientConn(t, &mockClimateService{
turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "off"}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.TurnOff(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
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 resp.GetState().GetState() != "off" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCIncreaseTemperature(t *testing.T) {
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 := newClimateTestClientConn(t, &mockClimateService{
increaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
gotID = id
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.IncreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
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 != "climate.air_conditioner" {
t.Fatalf("IncreaseTemperature id = %q, want %q", gotID, "climate.air_conditioner")
}
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCDecreaseTemperature(t *testing.T) {
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) {
conn := newClimateTestClientConn(t, &mockClimateService{
decreaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.DecreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
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 resp.GetState().GetEntityId() != "climate.air_conditioner" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCSetHVACMode(t *testing.T) {
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
var gotMode string
conn := newClimateTestClientConn(t, &mockClimateService{
setHVACModeFunc: func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
gotID = id
gotMode = hvacMode
if tt.err != nil {
return nil, tt.err
}
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.SetHVACMode(context.Background(), &hav1.SetHVACModeRequest{EntityId: "climate.air_conditioner", HvacMode: "cool"})
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 != "climate.air_conditioner" || gotMode != "cool" {
t.Fatalf("SetHVACMode id/mode = %q/%q, want %q/%q", gotID, gotMode, "climate.air_conditioner", "cool")
}
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCListClimates(t *testing.T) {
tests := []struct {
name string
err error
wantCode codes.Code
}{
{name: "happy path with multiple climates", 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) {
current := 21.5
target := 22.0
conn := newClimateTestClientConn(t, &mockClimateService{
listClimatesFunc: func(ctx context.Context) ([]domain.Climate, error) {
if tt.err != nil {
return nil, tt.err
}
return []domain.Climate{
{
EntityID: "climate.air_conditioner",
FriendlyName: "Air Conditioner",
State: "cool",
HVACModes: []string{"cool", "heat", "off"},
FanMode: "auto",
FanModes: []string{"auto", "low"},
CurrentTemperature: &current,
TargetTemperature: &target,
TargetTempStep: 1,
MinTemp: 7,
MaxTemp: 35,
},
{EntityID: "climate.bedroom", State: "off"},
}, nil
},
})
client := hav1.NewClimateServiceClient(conn)
resp, err := client.ListClimates(context.Background(), &hav1.ListClimatesRequest{})
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.GetClimates()) != 2 {
t.Fatalf("len(climates) = %d, want 2", len(resp.GetClimates()))
}
first := resp.GetClimates()[0]
if first.GetEntityId() != "climate.air_conditioner" {
t.Fatalf("climates[0].EntityId = %q, want %q", first.GetEntityId(), "climate.air_conditioner")
}
if first.GetCurrentTemperature() != 21.5 || first.GetTargetTemperature() != 22.0 {
t.Fatalf("climates[0] temperatures = (%v, %v), want (21.5, 22.0)", first.GetCurrentTemperature(), first.GetTargetTemperature())
}
second := resp.GetClimates()[1]
if second.CurrentTemperature != nil || second.TargetTemperature != nil {
t.Fatalf("climates[1] should have no temperature set, got current=%v target=%v", second.GetCurrentTemperature(), second.GetTargetTemperature())
}
})
}
}
func newClimateTestClientConn(t *testing.T, svc *mockClimateService) *grpc.ClientConn {
t.Helper()
lis := bufconn.Listen(testBufSize)
server := grpc.NewServer()
hav1.RegisterClimateServiceServer(server, NewClimateGRPC(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
}