package driver import ( "context" "encoding/json" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/protocol" ) func TestRouterKeepsSinglesAndBatchesInOneDeviceOrder(t *testing.T) { router, err := NewRouter([]string{testDeviceID}) require.NoError(t, err) queue, exists := router.Queue(testDeviceID) require.True(t, exists) first := routerCommand(t, testDeviceID, "relay", time.Minute) second := routerCommand(t, testDeviceID, "output", time.Minute) third := routerCommand(t, testDeviceID, "pulse", time.Minute) last := routerCommand(t, testDeviceID, "batch", time.Minute) batch, err := protocol.BuildCommandBatch(testDeviceID, second.Metadata, protocol.BatchPayload{ Commands: []protocol.BatchCommand{ { Metadata: second.Metadata, Capability: second.Capability, Payload: second.Payload, }, { Metadata: third.Metadata, Capability: third.Capability, Payload: third.Payload, }, }, }) require.NoError(t, err) for _, publication := range []protocol.Publication{commandPublication(t, first), batch, commandPublication(t, last)} { require.NoError(t, router.Handle(t.Context(), publication.Topic, publication.Data, publication.Retain)) } require.Len(t, queue, 3) require.Equal(t, []Command{first}, <-queue) require.Equal(t, []Command{second, third}, <-queue) require.Equal(t, []Command{last}, <-queue) } func TestRouterBuildsOnlyExplicitDeviceSubscriptions(t *testing.T) { router, err := NewRouter([]string{testOtherDeviceID, testDeviceID}) require.NoError(t, err) first, err := protocol.CommandSubscription(testDeviceID) require.NoError(t, err) second, err := protocol.CommandSubscription(testOtherDeviceID) require.NoError(t, err) subscriptions := router.Subscriptions() require.Equal(t, []protocol.Subscription{first, second}, subscriptions) subscriptions[0].Filter = "changed" require.Equal(t, first, router.Subscriptions()[0]) queue, exists := router.Queue("unknown") require.False(t, exists) require.Nil(t, queue) _, err = NewRouter([]string{"invalid"}) require.Error(t, err) _, err = NewRouter([]string{testDeviceID, testDeviceID}) require.Error(t, err) readOnly, err := NewRouter(nil) require.NoError(t, err) require.Empty(t, readOnly.Subscriptions()) } func TestRouterRejectsInvalidUnownedAndExpiredDeliveries(t *testing.T) { router, err := NewRouter([]string{testDeviceID}) require.NoError(t, err) queue, exists := router.Queue(testDeviceID) require.True(t, exists) valid := commandPublication(t, routerCommand(t, testDeviceID, "relay", time.Minute)) unowned := commandPublication(t, routerCommand(t, testOtherDeviceID, "relay", time.Minute)) expired := commandPublication(t, routerCommand(t, testDeviceID, "relay", -time.Second)) value, err := protocol.BuildReported(testDeviceID, "relay", protocolMetadata(t), protocol.ValuePayload{Value: json.RawMessage("true")}) require.NoError(t, err) availability, err := protocol.BuildAvailability(testDeviceID, protocolMetadata(t), protocol.AvailabilityPayload{Status: "online"}) require.NoError(t, err) testCases := []struct { name string publication protocol.Publication }{ { name: "retained command rejects", publication: protocol.Publication{ Topic: valid.Topic, Data: valid.Data, Retain: true, }, }, { name: "malformed payload rejects", publication: protocol.Publication{ Topic: valid.Topic, Data: []byte(`{}`), }, }, { name: "unknown topic rejects", publication: protocol.Publication{ Topic: "invalid", Data: valid.Data, }, }, { name: "unowned device rejects", publication: unowned, }, { name: "expired command rejects", publication: expired, }, { name: "reported value is not command intake", publication: value, }, { name: "availability is not command intake", publication: availability, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Error(t, router.Handle(t.Context(), tc.publication.Topic, tc.publication.Data, tc.publication.Retain)) require.Empty(t, queue) }) } } func TestRouterRejectsWholeBatchWhenOneMemberExpires(t *testing.T) { router, err := NewRouter([]string{testDeviceID}) require.NoError(t, err) first := routerCommand(t, testDeviceID, "relay", time.Minute) second := routerCommand(t, testDeviceID, "pulse", -time.Second) batch, err := protocol.BuildCommandBatch(testDeviceID, first.Metadata, protocol.BatchPayload{ Commands: []protocol.BatchCommand{ { Metadata: first.Metadata, Capability: first.Capability, Payload: first.Payload, }, { Metadata: second.Metadata, Capability: second.Capability, Payload: second.Payload, }, }, }) require.NoError(t, err) require.Error(t, router.Handle(t.Context(), batch.Topic, batch.Data, false)) queue, exists := router.Queue(testDeviceID) require.True(t, exists) require.Empty(t, queue) } func TestRouterBackpressureHonorsCancellationAndEarliestExpiry(t *testing.T) { synctest.Test(t, func(t *testing.T) { router, err := NewRouter([]string{testDeviceID}) require.NoError(t, err) valid := commandPublication(t, routerCommand(t, testDeviceID, "relay", time.Hour)) for range commandQueueCapacity { require.NoError(t, router.Handle(t.Context(), valid.Topic, valid.Data, false)) } ctx, cancel := context.WithCancel(t.Context()) defer cancel() finished := make(chan error, 1) go func() { finished <- router.Handle(ctx, valid.Topic, valid.Data, false) }() synctest.Wait() require.Empty(t, finished) cancel() require.ErrorIs(t, <-finished, context.Canceled) short := commandPublication(t, routerCommand(t, testDeviceID, "pulse", time.Second)) require.ErrorIs(t, router.Handle(t.Context(), short.Topic, short.Data, false), context.DeadlineExceeded) first := routerCommand(t, testDeviceID, "relay", time.Minute) second := routerCommand(t, testDeviceID, "pulse", time.Second) batch, err := protocol.BuildCommandBatch(testDeviceID, first.Metadata, protocol.BatchPayload{Commands: []protocol.BatchCommand{ { Metadata: first.Metadata, Capability: first.Capability, Payload: first.Payload, }, { Metadata: second.Metadata, Capability: second.Capability, Payload: second.Payload, }, }}) require.NoError(t, err) start := time.Now() require.ErrorIs(t, router.Handle(t.Context(), batch.Topic, batch.Data, false), context.DeadlineExceeded) require.Equal(t, time.Second, time.Since(start)) queue, exists := router.Queue(testDeviceID) require.True(t, exists) require.Len(t, queue, commandQueueCapacity) require.ErrorIs(t, router.Handle(ctx, valid.Topic, valid.Data, false), context.Canceled) <-queue require.NoError(t, router.Handle(t.Context(), valid.Topic, valid.Data, false)) }) } func routerCommand(t *testing.T, deviceID string, capability string, lifetime time.Duration) Command { t.Helper() metadata := protocolMetadata(t) expiry := time.Now().UTC().Add(lifetime) metadata.ExpiresAt = &expiry return Command{ DeviceID: deviceID, Capability: capability, Metadata: metadata, Payload: protocol.ValuePayload{Value: json.RawMessage("true")}, } } func commandPublication(t *testing.T, command Command) protocol.Publication { t.Helper() publication, err := protocol.BuildCommand(command.DeviceID, command.Capability, command.Metadata, command.Payload) require.NoError(t, err) return publication } func protocolMetadata(t *testing.T) protocol.Metadata { t.Helper() now := time.Now().UTC() id, err := protocol.NewID(now) require.NoError(t, err) return protocol.Metadata{ MessageID: id, SentAt: &now, } }