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 }