+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,
+ }
+}