9 "github.com/stretchr/testify/require"
12 func TestPayloadValidationRejectsInvalidContracts(t *testing.T) {
13 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
18 descriptor := `{"manufacturer":"example","model":"relay","firmware":"1","capabilities":[{"id":"relay","type":"switch","reports_state":true,"accepts_commands":false}]}`
19 testCases := []struct {
26 name: "rejects unknown descriptor field",
28 payload: strings.Replace(descriptor, `"model":"relay"`, `"model":"relay","room":"kitchen"`, 1),
31 name: "rejects unknown capability field",
33 payload: strings.Replace(descriptor, `"type":"switch"`, `"type":"switch","extra":true`, 1),
36 name: "requires both access flags",
38 payload: strings.Replace(descriptor, `,"accepts_commands":false`, ``, 1),
41 name: "rejects null access flag",
43 payload: strings.Replace(descriptor, `"accepts_commands":false`, `"accepts_commands":null`, 1),
46 name: "requires active access mode",
48 payload: strings.Replace(descriptor, `"reports_state":true`, `"reports_state":false`, 1),
51 name: "requires device metadata",
53 payload: strings.Replace(descriptor, `"model":"relay"`, `"model":""`, 1),
56 name: "requires capabilities",
58 payload: `{"manufacturer":"example","model":"relay","firmware":"1","capabilities":[]}`,
61 name: "rejects invalid capability identifier",
63 payload: strings.Replace(descriptor, `"id":"relay"`, `"id":"relay/1"`, 1),
66 name: "rejects duplicate capability",
68 payload: strings.Replace(descriptor, `}]`, `},{"id":"relay","type":"switch","reports_state":true,"accepts_commands":false}]`, 1),
71 name: "rejects unknown availability status",
73 payload: `{"status":"ready"}`,
76 name: "rejects unknown availability field",
78 payload: `{"status":"online","extra":1}`,
81 name: "requires report value",
87 name: "rejects unknown report field",
90 payload: `{"value":true,"extra":1}`,
93 name: "requires telemetry value",
99 name: "rejects unknown telemetry field",
102 payload: `{"value":1,"extra":1}`,
105 name: "rejects unknown command field",
108 payload: `{"value":true,"extra":1}`,
111 name: "requires command value",
117 name: "rejects array event",
123 name: "rejects unknown result field",
126 payload: `{"status":"completed","extra":1}`,
129 name: "rejects unknown hub field",
131 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","extra":1}`,
134 name: "rejects unknown diagnostic field",
136 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","diagnostics":{"observed_at":"2026-01-01T00:00:00Z","stale":false,"extra":1}}`,
139 name: "rejects unknown pairing field",
141 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","pairing":{"extra":1}}`,
144 name: "rejects hub identity mismatch",
146 payload: `{"id":"` + commandID + `","status":"ready","address":"example.com"}`,
149 name: "rejects invalid hub status",
151 payload: `{"id":"` + deviceID + `","status":"online","address":"example.com"}`,
155 for _, tc := range testCases {
156 t.Run(tc.name, func(t *testing.T) {
157 data, err := encodeEnvelope(metadata, json.RawMessage(tc.payload))
158 if tc.kind == Event {
159 require.Error(t, err)
164 require.NoError(t, err)
166 topic, err := (Address{
171 require.NoError(t, err)
173 _, err = Parse(topic, data, false)
174 require.Error(t, err)
179 func TestBuildersApplyCommandAndResultRules(t *testing.T) {
180 metadata := Metadata{MessageID: messageID}
181 _, err := BuildCommand(deviceID, "relay", metadata, ValuePayload{Value: json.RawMessage(`true`)})
182 require.Error(t, err)
184 _, err = BuildHubStatus(deviceID, metadata, HubStatusPayload{
186 Address: "example.com",
189 require.Error(t, err)
191 _, err = BuildDescriptor("bad", metadata, testDescriptor())
192 require.Error(t, err)
194 _, err = BuildReported(deviceID, "relay", metadata, ValuePayload{Value: json.RawMessage(`bad`)})
195 require.Error(t, err)
197 _, err = BuildAvailability(deviceID, metadata, AvailabilityPayload{Status: "bad"})
198 require.Error(t, err)
200 for _, value := range []string{`null`, `true`, `12`, `"open"`, `{"mode":"off"}`, `[1,2]`} {
201 var publication Publication
203 publication, err = BuildReported(deviceID, "point", metadata, ValuePayload{Value: json.RawMessage(value)})
204 require.NoError(t, err)
206 var message Message[ValuePayload]
208 message, err = ParseReported(publication.Topic, publication.Data, false)
209 require.NoError(t, err)
210 require.JSONEq(t, value, string(message.Payload.Value))
213 testCases := []struct {
220 name: "accepts intermediate result",
225 name: "accepts completed result",
230 name: "accepts rejection reason",
232 reason: "unsupported",
236 name: "accepts failure reason",
242 name: "requires rejection reason",
246 name: "requires failure reason",
250 name: "rejects successful reason",
255 name: "rejects unknown result status",
260 for _, tc := range testCases {
261 t.Run(tc.name, func(t *testing.T) {
262 correlated := Metadata{
263 MessageID: messageID,
264 CorrelationID: commandID,
267 var publication Publication
269 publication, err = BuildResult(deviceID, commandID, correlated, ResultPayload{
274 require.Error(t, err)
279 require.NoError(t, err)
281 var parsed Message[ResultPayload]
283 parsed, err = ParseResult(publication.Topic, publication.Data, false)
284 require.NoError(t, err)
285 require.Equal(t, tc.status, parsed.Payload.Status)
289 _, err = BuildResult(deviceID, commandID, metadata, ResultPayload{Status: "completed"})
290 require.Error(t, err)
292 descriptor := testDescriptor()
293 descriptor.Attribution = strings.Repeat("x", 2001)
294 _, err = BuildDescriptor(deviceID, metadata, descriptor)
295 require.Error(t, err)
298 func TestBuildersKeepNullValuesWithoutChangingInputs(t *testing.T) {
299 metadata := Metadata{MessageID: messageID}
300 payload := ValuePayload{}
301 publication, err := BuildReported(deviceID, "relay", metadata, payload)
302 require.NoError(t, err)
303 require.Nil(t, payload.Value)
305 parsed, err := ParseReported(publication.Topic, publication.Data, false)
306 require.NoError(t, err)
307 require.JSONEq(t, `null`, string(parsed.Payload.Value))
309 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
310 batch := testBatch(deadline)
311 batch.Commands[0].Payload.Value = nil
312 publication, err = BuildCommandBatch(deviceID, metadata, batch)
313 require.NoError(t, err)
314 require.Nil(t, batch.Commands[0].Payload.Value)
316 commands, err := ParseCommandBatch(publication.Topic, publication.Data, false)
317 require.NoError(t, err)
318 require.JSONEq(t, `null`, string(commands.Payload.Commands[0].Payload.Value))
321 func TestBuildEventRejectsNonObjectAndInvalidValues(t *testing.T) {
322 testCases := []struct {
328 name: "rejects null instead of an object",
331 name: "rejects malformed event data",
332 payload: EventPayload{"action": json.RawMessage(`bad`)},
335 name: "rejects empty nonnull event data",
336 payload: EventPayload{"action": json.RawMessage{}},
339 name: "keeps null event members",
340 payload: EventPayload{"action": nil},
345 for _, tc := range testCases {
346 t.Run(tc.name, func(t *testing.T) {
347 publication, err := BuildEvent(deviceID, "press", Metadata{MessageID: messageID}, tc.payload)
349 require.Error(t, err)
354 require.NoError(t, err)
356 parsed, err := ParseEvent(publication.Topic, publication.Data, false)
357 require.NoError(t, err)
358 require.JSONEq(t, `null`, string(parsed.Payload["action"]))
363 func TestBuildRejectsInvalidMetadataBeforeReturningPublications(t *testing.T) {
364 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
365 metadata := Metadata{MessageID: messageID}
366 testCases := []struct {
368 mutate func(*Metadata)
371 name: "rejects malformed message identity",
372 mutate: func(metadata *Metadata) {
373 metadata.MessageID = "bad"
377 name: "rejects malformed correlation identity",
378 mutate: func(metadata *Metadata) {
379 metadata.CorrelationID = "bad"
383 name: "rejects malformed causation identity",
384 mutate: func(metadata *Metadata) {
385 metadata.CausationID = "bad"
389 name: "rejects non UTC expiry",
390 mutate: func(metadata *Metadata) {
391 expiry := deadline.In(time.FixedZone("test", 3600))
392 metadata.ExpiresAt = &expiry
397 for _, tc := range testCases {
398 t.Run(tc.name, func(t *testing.T) {
402 _, err := BuildReported(deviceID, "relay", invalid, ValuePayload{Value: json.RawMessage(`true`)})
403 require.Error(t, err)
405 batch := testBatch(deadline)
406 tc.mutate(&batch.Commands[1].Metadata)
408 _, err = BuildCommandBatch(deviceID, metadata, batch)
409 require.Error(t, err)
414 func TestDescriptorAttributionLimitUsesEncodedText(t *testing.T) {
415 payload := testDescriptor()
416 payload.Attribution = strings.Repeat("\xff", 667)
417 _, err := BuildDescriptor(deviceID, Metadata{MessageID: messageID}, payload)
418 require.Error(t, err)
420 payload.Attribution = strings.Repeat("\xff", 666)
421 publication, err := BuildDescriptor(deviceID, Metadata{MessageID: messageID}, payload)
422 require.NoError(t, err)
424 parsed, err := ParseDescriptor(publication.Topic, publication.Data, true)
425 require.NoError(t, err)
426 require.Len(t, parsed.Payload.Attribution, 1998)