+package alamo
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "onehouse/driver"
+ "onehouse/protocol"
+)
+
+func TestCommandHandlerOrdersPowerAndTargetWithActionReadback(t *testing.T) {
+ testCases := []struct {
+ name string
+ initialPower bool
+ value string
+ wantActions []string
+ }{
+ {
+ name: "target precedes power on",
+ value: `{"mode":"heat","target_temperature":22}`,
+ wantActions: []string{"read", "target", "on"},
+ },
+ {
+ name: "power off precedes target change",
+ initialPower: true,
+ value: `{"mode":"off","target_temperature":22}`,
+ wantActions: []string{"read", "off", "target"},
+ },
+ {
+ name: "active target change turns off then on",
+ initialPower: true,
+ value: `{"mode":"heat","target_temperature":22}`,
+ wantActions: []string{"read", "off", "target", "on"},
+ },
+ {
+ name: "equal settings still get fresh readback",
+ value: `{"mode":"off","target_temperature":20}`,
+ wantActions: []string{"read"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ state := testState()
+ state.Power = tc.initialPower
+ client := &fakeGateway{states: []State{state}}
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := &commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ command.Payload.Value = []byte(tc.value)
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command}))
+ require.Equal(t, tc.wantActions, actionNames(client.actions))
+ require.Len(t, transport.messages(t, protocol.Result), 1)
+
+ result := transport.messages(t, protocol.Result)[0]
+ require.Equal(t, protocol.ResultPayload{Status: "completed"}, result.Payload)
+
+ reported := transport.messages(t, protocol.Reported)
+ require.Len(t, reported, 1)
+ require.Equal(t, command.Metadata.MessageID, reported[0].Metadata.CausationID)
+
+ for index := 1; index < len(client.starts); index++ {
+ require.GreaterOrEqual(t, client.starts[index].Sub(client.starts[index-1]), 6*time.Second)
+ }
+ })
+ })
+ }
+}
+
+func TestCommandHandlerStopsAtUnmatchedIntermediateReadback(t *testing.T) {
+ testCases := []struct {
+ name string
+ power bool
+ ignorePower bool
+ ignoreTarget bool
+ wantActions []string
+ }{
+ {
+ name: "failed off confirmation prevents target write",
+ power: true,
+ ignorePower: true,
+ wantActions: []string{"read", "off"},
+ },
+ {
+ name: "failed target confirmation prevents power on",
+ ignoreTarget: true,
+ wantActions: []string{"read", "target"},
+ },
+ {
+ name: "failed power on confirmation does not complete",
+ ignorePower: true,
+ wantActions: []string{"read", "target", "on"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ state := testState()
+ state.Power = tc.power
+ client := &fakeGateway{
+ states: []State{state},
+ ignorePower: tc.ignorePower,
+ ignoreTarget: tc.ignoreTarget,
+ }
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{testCommand()}))
+ require.Equal(t, tc.wantActions, actionNames(client.actions))
+
+ result, ok := transport.messages(t, protocol.Result)[0].Payload.(protocol.ResultPayload)
+ require.True(t, ok)
+ require.Equal(t, "failed", result.Status)
+ require.NotEmpty(t, result.Reason)
+ })
+ })
+ }
+}
+
+func TestCommandHandlerReplaysFirstResultAfterFeedbackOrResultSendFailure(t *testing.T) {
+ for _, kind := range []protocol.Kind{protocol.Reported, protocol.Result, protocol.Availability} {
+ t.Run(string(kind), func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ failure := errors.New("transport unavailable")
+ transport := &fakeTransport{
+ failKind: kind,
+ failure: failure,
+ }
+ client := &fakeGateway{states: []State{testState()}}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ require.ErrorIs(t, handler.handle(t.Context(), []driver.Command{command}), failure)
+
+ calls := len(client.actions)
+ record, exists := integration.thermostats[0].ledger.Lookup(command.Metadata.MessageID)
+ require.True(t, exists)
+ require.Equal(t, "completed", record.Payload.Status)
+
+ transport.failKind = ""
+
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command, command}))
+ require.Len(t, client.actions, calls)
+
+ messages := transport.messages(t, protocol.Result)
+ last := messages[len(messages)-1]
+ require.Equal(t, record.Metadata, last.Metadata)
+ require.Equal(t, record.Payload, last.Payload)
+ })
+ })
+ }
+}
+
+func TestCommandHandlerRejectsInvalidCommandsWithoutGatewayWork(t *testing.T) {
+ testCases := []struct {
+ name string
+ change func(*driver.Command)
+ }{
+ {
+ name: "expired work is rejected",
+ change: func(command *driver.Command) {
+ expires := time.Now().Add(-time.Second).UTC()
+ command.Metadata.ExpiresAt = &expires
+ },
+ },
+ {
+ name: "missing expiry is rejected",
+ change: func(command *driver.Command) {
+ command.Metadata.ExpiresAt = nil
+ },
+ },
+ {
+ name: "another capability is rejected",
+ change: func(command *driver.Command) {
+ command.Capability = "other"
+ },
+ },
+ {
+ name: "incomplete settings are rejected",
+ change: func(command *driver.Command) {
+ command.Payload.Value = []byte(`{}`)
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ client := &fakeGateway{}
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ tc.change(&command)
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command, command}))
+ require.Empty(t, client.starts)
+
+ messages := transport.messages(t, protocol.Result)
+ require.Len(t, messages, 2)
+ require.Equal(t, messages[0], messages[1])
+
+ result, ok := messages[0].Payload.(protocol.ResultPayload)
+ require.True(t, ok)
+ require.Equal(t, "rejected", result.Status)
+ })
+ }
+}
+
+func TestCommandHandlerCommandExpiryStopsLaterActionsAndReplay(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ client := &fakeGateway{states: []State{testState()}}
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ expires := time.Now().Add(7 * time.Second).UTC()
+ command.Metadata.ExpiresAt = &expires
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command}))
+ require.Equal(t, []string{"read", "target"}, actionNames(client.actions))
+ require.False(t, client.states[0].Power)
+
+ reported := transport.messages(t, protocol.Reported)
+ require.Len(t, reported, 1)
+ require.Equal(t, client.starts[1].UTC(), *reported[0].Metadata.SentAt)
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command}))
+ require.Len(t, client.actions, 2)
+
+ messages := transport.messages(t, protocol.Result)
+ require.Len(t, messages, 2)
+ require.Equal(t, messages[0], messages[1])
+ require.Equal(t, protocol.ResultPayload{
+ Status: "failed",
+ Reason: "Command expired",
+ }, messages[0].Payload)
+ })
+}
+
+func TestCommandHandlerRetainsFailuresWithoutRepeatingPhysicalActions(t *testing.T) {
+ for _, position := range []int{1, 2, 3, 4} {
+ t.Run(actionFailureName(position), func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ state := testState()
+ state.Power = true
+ client := &fakeGateway{
+ states: []State{state},
+ failActionAt: position,
+ }
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ require.NoError(t, handler.handle(t.Context(), []driver.Command{command, command}))
+ require.Len(t, client.actions, position)
+
+ messages := transport.messages(t, protocol.Result)
+ require.Len(t, messages, 2)
+ require.Equal(t, messages[0], messages[1])
+
+ payload, ok := messages[0].Payload.(protocol.ResultPayload)
+ require.True(t, ok)
+ require.Equal(t, "failed", payload.Status)
+ })
+ })
+ }
+}
+
+func TestCommandHandlerValidatesOwnershipAndReturnsPublicationFailures(t *testing.T) {
+ client := &fakeGateway{}
+ failure := errors.New("result unavailable")
+ transport := &fakeTransport{
+ failKind: protocol.Result,
+ failure: failure,
+ }
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ command := testCommand()
+ command.DeviceID = testSecondID
+ require.ErrorContains(t, handler.handle(t.Context(), []driver.Command{command}), "not owned")
+
+ command = testCommand()
+ command.Capability = "invalid"
+ require.ErrorIs(t, handler.handle(t.Context(), []driver.Command{command}), failure)
+ require.ErrorIs(t, handler.handle(t.Context(), []driver.Command{command}), failure)
+ require.Empty(t, client.starts)
+}
+
+func TestCommandHandlerCancellationStopsBeforeAnotherActionAndKeepsOutcome(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ client := &fakeGateway{states: []State{testState()}}
+ transport := &fakeTransport{}
+ integration, _ := testDriver(t, client, transport)
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+ command := testCommand()
+
+ go func() {
+ done <- handler.handle(ctx, []driver.Command{command})
+ }()
+
+ synctest.Wait()
+ cancel()
+ require.ErrorIs(t, <-done, context.Canceled)
+ require.Len(t, client.actions, 1)
+
+ _, exists := integration.thermostats[0].ledger.Lookup(command.Metadata.MessageID)
+ require.True(t, exists)
+ require.Empty(t, transport.messages(t, protocol.Result))
+ })
+}
+
+func TestCommandHandlerKeepsIndependentFailureDuringCancellation(t *testing.T) {
+ failure := errors.New("independent gateway failure")
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ client := &cancelingGateway{
+ cancel: cancel,
+ failure: failure,
+ }
+ integration, _ := testDriver(t, client, &fakeTransport{})
+ handler := commandHandler{
+ gateway: &integration.gateway,
+ thermostat: integration.thermostats[0],
+ }
+ err := handler.handle(ctx, []driver.Command{testCommand()})
+ require.ErrorIs(t, err, context.Canceled)
+ require.ErrorIs(t, err, failure)
+}
+
+func actionNames(actions []Action) []string {
+ var names []string
+
+ for _, action := range actions {
+ name := "read"
+ if action.TargetTemperature != nil {
+ name = "target"
+ }
+
+ if action.Power != nil {
+ name = "off"
+ if *action.Power {
+ name = "on"
+ }
+ }
+
+ names = append(names, name)
+ }
+
+ return names
+}
+
+func actionFailureName(position int) string {
+ return []string{"", "failed initial read", "failed power off", "failed target write", "failed power on"}[position]
+}