9 "github.com/stretchr/testify/require"
12 func TestTypedPublicationsRoundTrip(t *testing.T) {
13 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14 deadline := now.Add(time.Minute)
19 commandMetadata := Metadata{
23 resultMetadata := Metadata{
25 CorrelationID: commandID,
27 testCases := []struct {
30 build func() (Publication, error)
36 name: "preserves metadata contract",
41 build: func() (Publication, error) {
42 return BuildDescriptor(deviceID, metadata, testDescriptor())
44 payload: testDescriptor(),
47 name: "preserves online reachability",
52 build: func() (Publication, error) {
53 return BuildAvailability(deviceID, metadata, AvailabilityPayload{Status: "online"})
55 payload: AvailabilityPayload{Status: "online"},
58 name: "preserves retained object value",
63 build: func() (Publication, error) {
64 return BuildReported(deviceID, "weather", metadata, ValuePayload{Value: json.RawMessage(`{"temperature":20,"humidity":50}`)})
66 payload: ValuePayload{Value: json.RawMessage(`{"temperature":20,"humidity":50}`)},
69 name: "preserves unconfirmed sample",
73 build: func() (Publication, error) {
74 return BuildTelemetry(deviceID, "power", metadata, ValuePayload{Value: json.RawMessage(`12.5`)})
76 payload: ValuePayload{Value: json.RawMessage(`12.5`)},
79 name: "preserves open event data",
83 build: func() (Publication, error) {
84 return BuildEvent(deviceID, "motion", metadata, EventPayload{
85 "action": json.RawMessage(`"motion"`),
86 "custom": json.RawMessage(`7`),
89 payload: EventPayload{
90 "action": json.RawMessage(`"motion"`),
91 "custom": json.RawMessage(`7`),
95 name: "preserves command deadline",
99 build: func() (Publication, error) {
100 return BuildCommand(deviceID, "relay", commandMetadata, ValuePayload{Value: json.RawMessage(`true`)})
102 payload: ValuePayload{Value: json.RawMessage(`true`)},
105 name: "preserves ordered member requests",
109 build: func() (Publication, error) {
110 return BuildCommandBatch(deviceID, metadata, testBatch(deadline))
112 payload: testBatch(deadline),
115 name: "preserves correlated result",
119 build: func() (Publication, error) {
120 return BuildResult(deviceID, commandID, resultMetadata, ResultPayload{Status: "completed"})
122 payload: ResultPayload{Status: "completed"},
125 name: "preserves expiring hub snapshot",
130 build: func() (Publication, error) {
131 return BuildHubStatus(deviceID, commandMetadata, HubStatusPayload{
133 Address: "example.com",
137 payload: HubStatusPayload{
139 Address: "example.com",
145 for _, tc := range testCases {
146 t.Run(tc.name, func(t *testing.T) {
147 publication, err := tc.build()
148 require.NoError(t, err)
149 require.Equal(t, tc.retain, publication.Retain)
150 require.Equal(t, tc.qos, publication.QoS)
152 parsed, err := Parse(publication.Topic, publication.Data, publication.Retain)
153 require.NoError(t, err)
154 require.Equal(t, tc.kind, parsed.Address.Kind)
155 require.Equal(t, deviceID, parsed.Address.ID)
156 require.Equal(t, tc.payload, parsed.Payload)
157 require.Equal(t, messageID, parsed.Metadata.MessageID)
159 live, err := Parse(publication.Topic, publication.Data, false)
160 require.NoError(t, err)
161 require.False(t, live.Retained)
164 _, err = Parse(publication.Topic, publication.Data, true)
165 require.Error(t, err)
168 malformed := strings.Replace(publication.Topic, Root, "wrong/v1", 1)
169 _, err = Parse(malformed, publication.Data, false)
170 require.Error(t, err)
172 unknown := strings.Replace(string(publication.Data), `"schema":1`, `"schema":1,"unknown":true`, 1)
173 _, err = Parse(publication.Topic, []byte(unknown), false)
174 require.Error(t, err)
176 wrongSchema := strings.Replace(string(publication.Data), `"schema":1`, `"schema":2`, 1)
177 _, err = Parse(publication.Topic, []byte(wrongSchema), false)
178 require.Error(t, err)
180 if tc.kind == Command || tc.kind == CommandBatch || tc.kind == HubStatus {
181 require.Equal(t, &deadline, publication.ExpiresAt)
184 if tc.kind == Command {
185 response, err := parseTopic(publication.ResponseTopic)
186 require.NoError(t, err)
187 require.Equal(t, Result, response.Kind)
188 require.Equal(t, messageID, response.Target)
189 require.Equal(t, messageID, publication.CorrelationData)
191 require.Empty(t, publication.ResponseTopic)
192 require.Empty(t, publication.CorrelationData)
198 func TestTypedParsersRejectWrongOperations(t *testing.T) {
199 parsers := []func(string, []byte, bool) error{
200 func(topic string, data []byte, retained bool) error {
201 _, err := ParseDescriptor(topic, data, retained)
204 func(topic string, data []byte, retained bool) error {
205 _, err := ParseAvailability(topic, data, retained)
208 func(topic string, data []byte, retained bool) error {
209 _, err := ParseReported(topic, data, retained)
212 func(topic string, data []byte, retained bool) error {
213 _, err := ParseTelemetry(topic, data, retained)
216 func(topic string, data []byte, retained bool) error {
217 _, err := ParseEvent(topic, data, retained)
220 func(topic string, data []byte, retained bool) error {
221 _, err := ParseCommand(topic, data, retained)
224 func(topic string, data []byte, retained bool) error {
225 _, err := ParseCommandBatch(topic, data, retained)
228 func(topic string, data []byte, retained bool) error {
229 _, err := ParseResult(topic, data, retained)
232 func(topic string, data []byte, retained bool) error {
233 _, err := ParseHubStatus(topic, data, retained)
238 for _, parse := range parsers {
239 require.Error(t, parse(Root+"/devices/"+deviceID+"/unknown", []byte(`{}`), false))
243 func TestTypedParsersKeepValidationAfterTopicDispatch(t *testing.T) {
244 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
245 metadata := Metadata{
246 MessageID: messageID,
247 ExpiresAt: &deadline,
248 CorrelationID: commandID,
250 testCases := []struct {
255 parse func(string, []byte, bool) (any, error)
258 name: "descriptor keeps its machine metadata",
260 payload: testDescriptor(),
261 parse: func(topic string, data []byte, retained bool) (any, error) {
262 parsed, err := ParseDescriptor(topic, data, retained)
263 return parsed.Payload, err
267 name: "availability keeps its reachability",
269 payload: AvailabilityPayload{Status: "online"},
270 parse: func(topic string, data []byte, retained bool) (any, error) {
271 parsed, err := ParseAvailability(topic, data, retained)
272 return parsed.Payload, err
276 name: "reported keeps its complete value",
279 payload: ValuePayload{Value: json.RawMessage(`true`)},
280 parse: func(topic string, data []byte, retained bool) (any, error) {
281 parsed, err := ParseReported(topic, data, retained)
282 return parsed.Payload, err
286 name: "telemetry keeps its sample",
289 payload: ValuePayload{Value: json.RawMessage(`12`)},
290 parse: func(topic string, data []byte, retained bool) (any, error) {
291 parsed, err := ParseTelemetry(topic, data, retained)
292 return parsed.Payload, err
296 name: "event keeps its open fields",
299 payload: EventPayload{"action": json.RawMessage(`"press"`)},
300 parse: func(topic string, data []byte, retained bool) (any, error) {
301 parsed, err := ParseEvent(topic, data, retained)
302 return parsed.Payload, err
306 name: "command keeps its requested value",
309 payload: ValuePayload{Value: json.RawMessage(`true`)},
310 parse: func(topic string, data []byte, retained bool) (any, error) {
311 parsed, err := ParseCommand(topic, data, retained)
312 return parsed.Payload, err
316 name: "command batch keeps its member order",
318 payload: testBatch(deadline),
319 parse: func(topic string, data []byte, retained bool) (any, error) {
320 parsed, err := ParseCommandBatch(topic, data, retained)
321 return parsed.Payload, err
325 name: "result keeps its correlated outcome",
328 payload: ResultPayload{Status: "completed"},
329 parse: func(topic string, data []byte, retained bool) (any, error) {
330 parsed, err := ParseResult(topic, data, retained)
331 return parsed.Payload, err
335 name: "hub status keeps its snapshot",
337 payload: HubStatusPayload{
339 Address: "example.com",
342 parse: func(topic string, data []byte, retained bool) (any, error) {
343 parsed, err := ParseHubStatus(topic, data, retained)
344 return parsed.Payload, err
349 for _, tc := range testCases {
350 t.Run(tc.name, func(t *testing.T) {
351 topic, err := (Address{
356 require.NoError(t, err)
358 data, err := encodeEnvelope(metadata, tc.payload)
359 require.NoError(t, err)
361 parsed, err := tc.parse(topic, data, false)
362 require.NoError(t, err)
363 require.Equal(t, tc.payload, parsed)
365 _, err = tc.parse(topic, []byte(`{}`), false)
366 require.Error(t, err)
368 wrongTopic := Root + "/devices/" + deviceID + "/availability"
369 if tc.kind == Availability {
370 wrongTopic = Root + "/devices/" + deviceID + "/descriptor"
373 _, err = tc.parse(wrongTopic, data, false)
374 require.Error(t, err)