package alamo import ( "context" "errors" "strings" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/driver" "onehouse/protocol" ) func TestDriverPollSharesOneSnapshotAcrossThermostats(t *testing.T) { synctest.Test(t, func(t *testing.T) { first := testState() second := first second.MAC = "fc610e7f98ab" client := &fakeGateway{states: []State{first, second}} transport := &fakeTransport{} integration, _ := testDriver(t, client, transport, Thermostat{ DeviceID: testDeviceID, MAC: testMAC, }, Thermostat{ DeviceID: testSecondID, MAC: second.MAC, }) err := integration.poll(t.Context()) require.NoError(t, err) require.Len(t, client.starts, 1) require.Len(t, transport.messages(t, protocol.Availability), 2) require.Len(t, transport.messages(t, protocol.Reported), 2) }) } func TestDriverMarksOnlyMissingThermostatOfflineAfterThreePollsAndRecovers(t *testing.T) { synctest.Test(t, func(t *testing.T) { second := testState() second.MAC = "fc610e7f98ab" client := &fakeGateway{states: []State{testState(), second}} transport := &fakeTransport{} integration, _ := testDriver(t, client, transport, Thermostat{ DeviceID: testDeviceID, MAC: testMAC, }, Thermostat{ DeviceID: testSecondID, MAC: second.MAC, }) require.NoError(t, integration.poll(t.Context())) client.states = []State{second} for range 2 { require.NoError(t, integration.poll(t.Context())) require.Len(t, transport.messages(t, protocol.Availability), 2) } require.NoError(t, integration.poll(t.Context())) messages := transport.messages(t, protocol.Availability) require.Len(t, messages, 3) require.Equal(t, testDeviceID, messages[2].Address.ID) require.Equal(t, protocol.AvailabilityPayload{Status: "offline"}, messages[2].Payload) require.NoError(t, integration.poll(t.Context())) require.Len(t, transport.messages(t, protocol.Availability), 3) client.states = []State{testState(), second} require.NoError(t, integration.poll(t.Context())) messages = transport.messages(t, protocol.Availability) require.Len(t, messages, 4) require.Equal(t, protocol.AvailabilityPayload{Status: "online"}, messages[3].Payload) }) } func TestDriverFailedPollsHaveNoPrematureStartupAvailability(t *testing.T) { synctest.Test(t, func(t *testing.T) { client := &fakeGateway{err: errors.New("gateway unavailable")} transport := &fakeTransport{} integration, _ := testDriver(t, client, transport) for range 2 { require.NoError(t, integration.poll(t.Context())) require.Empty(t, transport.messages(t, protocol.Availability)) } require.NoError(t, integration.poll(t.Context())) messages := transport.messages(t, protocol.Availability) require.Len(t, messages, 1) require.Equal(t, protocol.AvailabilityPayload{Status: "offline"}, messages[0].Payload) client.err = nil client.states = []State{testState()} require.NoError(t, integration.poll(t.Context())) client.err = errors.New("gateway unavailable") require.NoError(t, integration.poll(t.Context())) require.Len(t, transport.messages(t, protocol.Availability), 2) }) } func TestDriverRunForwardsCommandsRefreshesAndStopsWithoutOutage(t *testing.T) { synctest.Test(t, func(t *testing.T) { client := &fakeGateway{states: []State{testState()}} transport := &fakeTransport{} integration, router := testDriver(t, client, transport) ctx, cancel := context.WithCancel(t.Context()) defer cancel() integration.Refresh() integration.Refresh() done := make(chan error, 1) go func() { done <- integration.Run(ctx) }() synctest.Wait() require.Len(t, transport.messages(t, protocol.Descriptor), 1) require.Equal(t, 1, client.startCount()) time.Sleep(6 * time.Second) synctest.Wait() require.Equal(t, 2, client.startCount()) command := testCommand() publication, err := protocol.BuildCommand(command.DeviceID, command.Capability, command.Metadata, command.Payload) require.NoError(t, err) require.NoError(t, router.Handle(ctx, publication.Topic, publication.Data, false)) require.NoError(t, router.Handle(ctx, publication.Topic, publication.Data, false)) time.Sleep(40 * time.Second) synctest.Wait() require.Len(t, transport.messages(t, protocol.Result), 2) cancel() require.ErrorIs(t, <-done, context.Canceled) require.Len(t, client.actions, 3) messages := transport.messages(t, protocol.Availability) for _, message := range messages { require.Equal(t, protocol.AvailabilityPayload{Status: "online"}, message.Payload) } }) } func TestDriverConstructorValidatesAndCopiesSettings(t *testing.T) { router, err := driver.NewRouter([]string{testDeviceID, testSecondID}) require.NoError(t, err) publisher := driver.NewPublisher(&fakeTransport{}) valid := Thermostat{ DeviceID: testDeviceID, MAC: testMAC, } testCases := []struct { name string settings Settings }{ { name: "empty gateway is rejected", }, { name: "invalid ID is rejected", settings: Settings{Thermostats: []Thermostat{{ DeviceID: "bad", MAC: testMAC, }}}, }, { name: "invalid MAC is rejected", settings: Settings{Thermostats: []Thermostat{{ DeviceID: testDeviceID, MAC: "bad", }}}, }, { name: "duplicate ID is rejected", settings: Settings{Thermostats: []Thermostat{valid, valid}}, }, { name: "MAC uniqueness ignores letter case", settings: Settings{Thermostats: []Thermostat{valid, { DeviceID: testSecondID, MAC: strings.ToUpper(testMAC), }}}, }, { name: "poll interval cannot exceed five minutes", settings: Settings{ PollInterval: time.Hour, Thermostats: []Thermostat{valid}, }, }, { name: "poll interval cannot be below ten seconds", settings: Settings{ PollInterval: time.Second, Thermostats: []Thermostat{valid}, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err = New(tc.settings, publisher, router, &fakeGateway{}) require.Error(t, err) }) } emptyRouter, err := driver.NewRouter(nil) require.NoError(t, err) _, err = New(Settings{Thermostats: []Thermostat{valid}}, publisher, emptyRouter, &fakeGateway{}) require.ErrorContains(t, err, "queue") settings := Settings{ PollInterval: 10 * time.Second, Thermostats: []Thermostat{valid}, } integration, err := New(settings, publisher, router, &fakeGateway{}) require.NoError(t, err) settings.Thermostats[0].MAC = "bad" require.Equal(t, testMAC, integration.thermostats[0].settings.MAC) require.Equal(t, 10*time.Second, integration.pollInterval) } func TestDriverReturnsPublicationFailuresAndCallerCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { failure := errors.New("publication unavailable") transport := &fakeTransport{ failKind: protocol.Descriptor, failure: failure, } integration, _ := testDriver(t, &fakeGateway{states: []State{testState()}}, transport) require.ErrorIs(t, integration.Run(t.Context()), failure) transport.failKind = protocol.Reported require.ErrorIs(t, integration.Run(t.Context()), failure) transport.failKind = protocol.Availability integration.thermostats[0].missed = 2 integration.gateway.client = &fakeGateway{} require.ErrorIs(t, integration.poll(t.Context()), failure) ctx, cancel := context.WithCancel(t.Context()) cancel() require.ErrorIs(t, integration.poll(ctx), context.Canceled) }) } func TestDriverReturnsRefreshAndPeriodicPublicationFailures(t *testing.T) { for _, refresh := range []bool{false, true} { t.Run(publicationFailureName(refresh), func(t *testing.T) { synctest.Test(t, func(t *testing.T) { client := &fakeGateway{states: []State{testState()}} transport := &fakeTransport{} integration, _ := testDriver(t, client, transport) ctx, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan error, 1) go func() { done <- integration.Run(ctx) }() synctest.Wait() failure := errors.New("publication unavailable") transport.mu.Lock() transport.failKind = protocol.Reported transport.failure = failure transport.mu.Unlock() client.mu.Lock() client.states[0].TargetTemperature = 25 client.mu.Unlock() if refresh { integration.Refresh() } require.ErrorIs(t, <-done, failure) }) }) } } func TestDriverReturnsCommandPublicationFailuresAndJoinsForwarders(t *testing.T) { synctest.Test(t, func(t *testing.T) { client := &fakeGateway{states: []State{testState()}} transport := &fakeTransport{ failKind: protocol.Result, failure: errors.New("result unavailable"), } integration, router := testDriver(t, client, transport) command := testCommand() publication, err := protocol.BuildCommand(command.DeviceID, command.Capability, command.Metadata, command.Payload) require.NoError(t, err) require.NoError(t, router.Handle(t.Context(), publication.Topic, publication.Data, false)) require.ErrorIs(t, integration.Run(t.Context()), transport.failure) }) } func TestDriverPollKeepsIndependentFailureDuringCancellation(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{}) err := integration.poll(ctx) require.ErrorIs(t, err, context.Canceled) require.ErrorIs(t, err, failure) } func publicationFailureName(refresh bool) string { if refresh { return "refresh failure returns to runner" } return "periodic failure returns to runner" }