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] }