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) setTemperatureFunc func(ctx context.Context, id domain.EntityID, target float64) (*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) SetTemperature(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) { if m.setTemperatureFunc == nil { return nil, nil } return m.setTemperatureFunc(ctx, id, target) } 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 TestClimateGRPCSetTemperature(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 gotTarget float64 conn := newClimateTestClientConn(t, &mockClimateService{ setTemperatureFunc: func(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) { gotID = id gotTarget = target 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.SetTemperature(context.Background(), &hav1.SetTemperatureRequest{EntityId: "climate.air_conditioner", TargetTemperature: 23.5}) 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" || gotTarget != 23.5 { t.Fatalf("SetTemperature id/target = %q/%v, want %q/%v", gotID, gotTarget, "climate.air_conditioner", 23.5) } 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: ¤t, 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 }