- 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.
323 lines
8.9 KiB
Go
323 lines
8.9 KiB
Go
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
|
|
}
|