package driver import ( "context" "encoding/json" "errors" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/protocol" ) func TestPublisherSuppressesEqualRetainedValuesAndKeepsCommandEvidence(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) metadata := protocol.Metadata{} descriptor := protocol.DescriptorPayload{ Manufacturer: "example", Model: "switch", Firmware: "1", Capabilities: []protocol.Capability{{ ID: "relay", Type: "switch", ReportsState: true, }}, } for range 2 { require.NoError(t, publisher.Descriptor(t.Context(), testDeviceID, metadata, descriptor)) require.NoError(t, publisher.Availability(t.Context(), testDeviceID, metadata, protocol.AvailabilityPayload{Status: "offline"})) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "relay", metadata, protocol.ValuePayload{Value: json.RawMessage("false")})) } require.Len(t, transport.messages(), 3) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "relay", protocol.Metadata{CorrelationID: testCommandID}, protocol.ValuePayload{Value: json.RawMessage("false")})) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "relay", protocol.Metadata{CausationID: testCommandID}, protocol.ValuePayload{Value: json.RawMessage("false")})) require.Len(t, transport.messages(), 5) for _, publication := range transport.messages() { require.Equal(t, byte(1), publication.QoS) require.True(t, publication.Retain) } } func TestPublisherRepeatsLatestMeasurementWithoutMovingDeadline(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() transport := &recordingTransport{} publisher := NewPublisher(transport) finished := make(chan error, 1) require.NoError(t, publisher.Reported(ctx, testDeviceID, "weather", protocol.Metadata{CorrelationID: testCommandID}, protocol.ValuePayload{Value: json.RawMessage(`{"temperature":20,"humidity":50}`)})) first := transport.messages()[0] go func() { finished <- publisher.Run(ctx) }() for range 299 { time.Sleep(time.Second) require.NoError(t, publisher.Telemetry(ctx, testDeviceID, "weather", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage(`{ "humidity":50,"temperature":20 }`)})) synctest.Wait() require.Len(t, transport.messages(), 1) } time.Sleep(time.Second) synctest.Wait() packets := transport.messages() require.Len(t, packets, 2) repeated, err := protocol.ParseReported(packets[1].Topic, packets[1].Data, packets[1].Retain) require.NoError(t, err) original, err := protocol.ParseReported(first.Topic, first.Data, first.Retain) require.NoError(t, err) require.NotEqual(t, original.Metadata.MessageID, repeated.Metadata.MessageID) require.Equal(t, time.Now().Add(-time.Second).UTC(), *repeated.Metadata.SentAt) require.Empty(t, repeated.Metadata.CorrelationID) require.Empty(t, repeated.Metadata.CausationID) require.JSONEq(t, `{"temperature":20,"humidity":50}`, string(repeated.Payload.Value)) cancel() require.ErrorIs(t, <-finished, context.Canceled) }) } func TestPublisherTelemetryKeepsSnapshotOnScheduleAndReconnect(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() transport := &recordingTransport{} publisher := NewPublisher(transport) finished := make(chan error, 1) require.NoError(t, publisher.Telemetry(ctx, testDeviceID, "power", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("10")})) go func() { finished <- publisher.Run(ctx) }() time.Sleep(4 * time.Minute) require.NoError(t, publisher.Telemetry(ctx, testDeviceID, "power", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("20")})) require.NoError(t, publisher.Telemetry(ctx, testOtherDeviceID, "power", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("30")})) synctest.Wait() packets := transport.messages() require.Len(t, packets, 3) for _, packet := range packets { require.Equal(t, byte(0), packet.QoS) require.False(t, packet.Retain) } time.Sleep(time.Minute) synctest.Wait() packets = transport.messages() require.Len(t, packets, 4) snapshot, err := protocol.ParseReported(packets[3].Topic, packets[3].Data, packets[3].Retain) require.NoError(t, err) require.JSONEq(t, "20", string(snapshot.Payload.Value)) require.Equal(t, byte(1), packets[3].QoS) require.Equal(t, time.Now().Add(-time.Minute).UTC(), *snapshot.Metadata.SentAt) publisher.Refresh() synctest.Wait() require.Len(t, transport.messages(), 6) cancel() require.ErrorIs(t, <-finished, context.Canceled) }) } func TestPublisherRepeatsOnlineButNotBooleanOrOffline(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() transport := &recordingTransport{} publisher := NewPublisher(transport) finished := make(chan error, 1) require.NoError(t, publisher.Availability(ctx, testDeviceID, protocol.Metadata{}, protocol.AvailabilityPayload{Status: "online"})) require.NoError(t, publisher.Availability(ctx, testOtherDeviceID, protocol.Metadata{}, protocol.AvailabilityPayload{Status: "offline"})) require.NoError(t, publisher.Reported(ctx, testDeviceID, "relay", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("true")})) go func() { finished <- publisher.Run(ctx) }() time.Sleep(5 * time.Minute) synctest.Wait() packets := transport.messages() require.Len(t, packets, 4) online, err := protocol.ParseAvailability(packets[3].Topic, packets[3].Data, packets[3].Retain) require.NoError(t, err) require.Equal(t, "online", online.Payload.Status) publisher.Refresh() synctest.Wait() require.Len(t, transport.messages(), 7) cancel() require.ErrorIs(t, <-finished, context.Canceled) }) } func TestPublisherDoesNotSuppressEventsResultsOrExpiringValues(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) expiry := time.Now().UTC().Add(time.Minute) for range 2 { require.NoError(t, publisher.Event(t.Context(), testDeviceID, "button", protocol.Metadata{}, protocol.EventPayload{"action": json.RawMessage(`"pressed"`)})) require.NoError(t, publisher.Result(t.Context(), testDeviceID, testCommandID, protocol.Metadata{CorrelationID: testCommandID}, protocol.ResultPayload{Status: "completed"})) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "relay", protocol.Metadata{ExpiresAt: &expiry}, protocol.ValuePayload{Value: json.RawMessage("true")})) } require.Len(t, transport.messages(), 6) require.NoError(t, publisher.repeat(t.Context(), true)) require.Len(t, transport.messages(), 6) } func TestPublisherKeepsFailedLatestSampleAndReturnsRepeatFailures(t *testing.T) { synctest.Test(t, func(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "temperature", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("20")})) failure := errors.New("broker unavailable") transport.setError(failure) require.ErrorIs(t, publisher.Telemetry(t.Context(), testDeviceID, "temperature", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("21")}), failure) require.ErrorIs(t, publisher.Telemetry(t.Context(), testDeviceID, "temperature", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("22")}), failure) publisher.Refresh() require.ErrorIs(t, publisher.Run(t.Context()), failure) transport.setError(nil) require.NoError(t, publisher.repeat(t.Context(), false)) packets := transport.messages() last := packets[len(packets)-1] snapshot, err := protocol.ParseReported(last.Topic, last.Data, last.Retain) require.NoError(t, err) require.JSONEq(t, "22", string(snapshot.Payload.Value)) }) } func TestPublisherValidatesBeforeSendingAndHonorsCancellation(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) require.Error(t, publisher.Reported(t.Context(), "invalid", "relay", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("true")})) require.Error(t, publisher.Reported(t.Context(), testDeviceID, "relay", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("invalid")})) past := time.Now().UTC().Add(-time.Second) require.ErrorContains(t, publisher.Event(t.Context(), testDeviceID, "button", protocol.Metadata{ExpiresAt: &past}, protocol.EventPayload{}), "expired") ctx, cancel := context.WithCancel(t.Context()) defer cancel() cancel() require.ErrorIs(t, publisher.Reported(ctx, testDeviceID, "relay", protocol.Metadata{}, protocol.ValuePayload{Value: json.RawMessage("true")}), context.Canceled) require.Empty(t, transport.messages()) } func TestPublisherSendTimeoutAndCallerCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { publisher := NewPublisher(&waitingTransport{}) err := publisher.Event(t.Context(), testDeviceID, "button", protocol.Metadata{}, protocol.EventPayload{}) require.ErrorIs(t, err, context.DeadlineExceeded) ctx, cancel := context.WithCancel(t.Context()) defer cancel() finished := make(chan error, 1) go func() { finished <- publisher.Event(ctx, testDeviceID, "button", protocol.Metadata{}, protocol.EventPayload{}) }() synctest.Wait() cancel() require.ErrorIs(t, <-finished, context.Canceled) }) } func TestPublisherCopiesSnapshotsAndAcceptsTypedNull(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) now := time.Now().UTC() metadata := protocol.Metadata{SentAt: &now} value := json.RawMessage(`{"power":10}`) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "power", metadata, protocol.ValuePayload{Value: value})) observation := now now = now.Add(time.Hour) copy(value, `{"power":99}`) publisher.Refresh() publisher.Refresh() require.NoError(t, publisher.repeat(t.Context(), true)) packets := transport.messages() require.Len(t, packets, 2) snapshot, err := protocol.ParseReported(packets[1].Topic, packets[1].Data, packets[1].Retain) require.NoError(t, err) require.JSONEq(t, `{"power":10}`, string(snapshot.Payload.Value)) require.Equal(t, observation, *snapshot.Metadata.SentAt) require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "power", metadata, protocol.ValuePayload{})) } func TestPublisherEqualTelemetryAtRepeatTimeSendsRetainedSnapshot(t *testing.T) { synctest.Test(t, func(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) value := protocol.ValuePayload{Value: json.RawMessage("null")} require.NoError(t, publisher.Telemetry(t.Context(), testDeviceID, "power", protocol.Metadata{}, value)) time.Sleep(protocol.MeasurementRepeatInterval) require.NoError(t, publisher.Telemetry(t.Context(), testDeviceID, "power", protocol.Metadata{}, value)) packets := transport.messages() require.Len(t, packets, 2) require.True(t, packets[1].Retain) snapshot, err := protocol.ParseReported(packets[1].Topic, packets[1].Data, packets[1].Retain) require.NoError(t, err) require.JSONEq(t, "null", string(snapshot.Payload.Value)) }) } func TestPublisherPreservesDirectMeasurementEvidenceAtRepeatDeadline(t *testing.T) { testCases := []struct { name string correlationID string causationID string }{ { name: "correlation keeps caller identity", correlationID: testCommandID, }, { name: "causation keeps caller identity", causationID: testCommandID, }, { name: "correlation and causation keep caller identity", correlationID: testCommandID, causationID: testOtherDeviceID, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { transport := &recordingTransport{} publisher := NewPublisher(transport) value := protocol.ValuePayload{Value: json.RawMessage("20")} require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "temperature", protocol.Metadata{}, value)) time.Sleep(protocol.MeasurementRepeatInterval) observedAt := time.Now().UTC() metadata := protocol.Metadata{ MessageID: testOtherDeviceID, SentAt: &observedAt, CorrelationID: tc.correlationID, CausationID: tc.causationID, } require.NoError(t, publisher.Reported(t.Context(), testDeviceID, "temperature", metadata, value)) packets := transport.messages() require.Len(t, packets, 2) message, err := protocol.ParseReported(packets[1].Topic, packets[1].Data, packets[1].Retain) require.NoError(t, err) require.Equal(t, metadata, message.Metadata) require.Equal(t, value, message.Payload) }) }) } } type waitingTransport struct{} func (p *waitingTransport) Publish(ctx context.Context, _ protocol.Publication) error { <-ctx.Done() return ctx.Err() }