package directive import ( "context" "errors" "io" "log/slog" "testing" "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa" "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain" ) func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type fakeResolver struct { entities map[string]domain.Entity } func (f *fakeResolver) Resolve(friendlyName string) (domain.Entity, bool) { e, ok := f.entities[friendlyName] return e, ok } type fakeController struct { executeFunc func(ctx context.Context, entityID, action string, params map[string]any) error gotEntityID string gotAction string gotParams map[string]any } func (f *fakeController) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error { f.gotEntityID = entityID f.gotAction = action f.gotParams = params if f.executeFunc == nil { return nil } return f.executeFunc(ctx, entityID, action, params) } func intentRequest(intentName string, slots map[string]alexa.Slot) alexa.Request { return alexa.Request{ Version: "1.0", Request: alexa.RequestBody{ Type: "IntentRequest", Intent: &alexa.Intent{ Name: intentName, Slots: slots, }, }, } } func TestRouterDispatchLaunchAndSessionEnded(t *testing.T) { r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger()) t.Run("LaunchRequest keeps the session open", func(t *testing.T) { resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "LaunchRequest"}}) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if resp.Response.ShouldEndSession { t.Fatal("LaunchRequest should not end the session") } }) t.Run("SessionEndedRequest returns an empty response", func(t *testing.T) { resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SessionEndedRequest"}}) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if resp.Version != "1.0" { t.Fatalf("Version = %q, want %q", resp.Version, "1.0") } }) t.Run("unrecognized request type does not error", func(t *testing.T) { resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SomethingElse"}}) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken fallback response") } }) } func TestRouterDispatchBuiltinIntents(t *testing.T) { r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger()) tests := []string{"AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.CancelIntent"} for _, name := range tests { t.Run(name, func(t *testing.T) { resp, err := r.Dispatch(context.Background(), intentRequest(name, nil)) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if resp.Response.OutputSpeech == nil || resp.Response.OutputSpeech.Text == "" { t.Fatal("expected spoken output") } }) } t.Run("unrecognized intent", func(t *testing.T) { resp, err := r.Dispatch(context.Background(), intentRequest("NoSuchIntent", nil)) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken fallback response") } }) } func TestRouterDispatchDeviceActions(t *testing.T) { entity := domain.Entity{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"} resolver := &fakeResolver{entities: map[string]domain.Entity{"living room lamp": entity}} t.Run("TurnOnIntent resolves device and executes turn_on with no params", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}}) resp, err := r.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotEntityID != "light.living_room" || ctrl.gotAction != "turn_on" { t.Fatalf("ExecuteAction called with (%q, %q), want (light.living_room, turn_on)", ctrl.gotEntityID, ctrl.gotAction) } if ctrl.gotParams != nil { t.Fatalf("params = %#v, want nil", ctrl.gotParams) } if !resp.Response.ShouldEndSession { t.Fatal("a completed action should end the session") } }) t.Run("SetBrightnessIntent forwards the Brightness slot as brightness_pct", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{ "Device": {Name: "Device", Value: "living room lamp"}, "Brightness": {Name: "Brightness", Value: "50"}, }) if _, err := r.Dispatch(context.Background(), req); err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotAction != "set_brightness" || ctrl.gotParams["brightness_pct"] != "50" { t.Fatalf("action/params = %q/%#v, want set_brightness/{brightness_pct:50}", ctrl.gotAction, ctrl.gotParams) } }) t.Run("SetTemperatureIntent forwards the Temperature slot as target_temperature", func(t *testing.T) { climateEntity := domain.Entity{EntityID: "climate.air_conditioner", FriendlyName: "Air Conditioner", Domain: "climate"} climateResolver := &fakeResolver{entities: map[string]domain.Entity{"air conditioner": climateEntity}} ctrl := &fakeController{} r := NewRouter(climateResolver, ctrl, discardLogger()) req := intentRequest("SetTemperatureIntent", map[string]alexa.Slot{ "Device": {Name: "Device", Value: "air conditioner"}, "Temperature": {Name: "Temperature", Value: "23.5"}, }) if _, err := r.Dispatch(context.Background(), req); err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotEntityID != "climate.air_conditioner" || ctrl.gotAction != "set_temperature" { t.Fatalf("entity/action = %q/%q, want climate.air_conditioner/set_temperature", ctrl.gotEntityID, ctrl.gotAction) } if ctrl.gotParams["target_temperature"] != "23.5" { t.Fatalf("params = %#v, want target_temperature=23.5", ctrl.gotParams) } }) t.Run("SetColorIntent forwards Red/Green/Blue slots as an rgb_color map", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("SetColorIntent", map[string]alexa.Slot{ "Device": {Name: "Device", Value: "living room lamp"}, "Red": {Name: "Red", Value: "255"}, "Green": {Name: "Green", Value: "0"}, "Blue": {Name: "Blue", Value: "0"}, }) if _, err := r.Dispatch(context.Background(), req); err != nil { t.Fatalf("Dispatch() error = %v", err) } rgb, ok := ctrl.gotParams["rgb_color"].(map[string]any) if !ok || rgb["r"] != "255" { t.Fatalf("rgb_color = %#v, want map with r=255", ctrl.gotParams["rgb_color"]) } }) t.Run("missing Device slot does not call the controller", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("TurnOnIntent", nil) resp, err := r.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotAction != "" { t.Fatal("controller should not be called without a Device slot") } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken response explaining the missing device") } }) t.Run("unresolvable device does not call the controller", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "nonexistent"}}) resp, err := r.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotAction != "" { t.Fatal("controller should not be called for an unresolvable device") } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken response explaining the device wasn't found") } }) t.Run("missing required action slot does not call the controller", func(t *testing.T) { ctrl := &fakeController{} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}}) resp, err := r.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch() error = %v", err) } if ctrl.gotAction != "" { t.Fatal("controller should not be called when a required param slot is missing") } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken response explaining the request wasn't understood") } }) t.Run("controller error is turned into a spoken failure, not a Dispatch error", func(t *testing.T) { ctrl := &fakeController{executeFunc: func(ctx context.Context, entityID, action string, params map[string]any) error { return errors.New("ha-gateway unreachable") }} r := NewRouter(resolver, ctrl, discardLogger()) req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}}) resp, err := r.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch() error = %v, want nil (errors should be spoken, not propagated)", err) } if resp.Response.OutputSpeech == nil { t.Fatal("expected a spoken failure response") } }) }