Files
mqtt-5-learning/internal/contracts/decode_test.go
T
Ryan fb30e8e884 Refactor MQTT course and bound backend message processing
Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section.

Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
2026-10-22 10:19:56 +03:30

77 lines
3.0 KiB
Go

package contracts
import (
"strings"
"testing"
"time"
)
var testTopic = Topic{DeviceID: "device-001", Kind: KindTelemetry}
var testNow = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
// Topic tests reject malformed routes and unsupported message kinds.
func TestParseTopic(t *testing.T) {
topic, err := ParseTopic("lab/v1/devices/device-001/sales")
if err != nil {
t.Fatal(err)
}
if topic.DeviceID != "device-001" || topic.Kind != KindSales {
t.Fatalf("unexpected topic: %#v", topic)
}
for _, invalid := range []string{"lab/v1/devices/+/sales", "lab/v1/devices/device-001/unknown", "lab/v1/devices/device-001/sales/extra"} {
if _, err := ParseTopic(invalid); err == nil {
t.Errorf("ParseTopic(%q) succeeded", invalid)
}
}
}
// These cases cover strict JSON shape and single-value decoding.
func TestDecodeRejectsUnknownAndTrailingValues(t *testing.T) {
if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"unknown":true}`), testTopic, testNow); err == nil {
t.Fatal("unknown field was accepted")
}
if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"} {"message_id":"m-2"}`), testTopic, testNow); err == nil {
t.Fatal("trailing JSON value was accepted")
}
}
// This case ensures the application limit is applied before decoding.
func TestDecodeRejectsOversizedPayload(t *testing.T) {
payload := []byte(`"` + strings.Repeat("x", MaxPayloadBytes) + `"`)
if _, err := Decode[Heartbeat](payload, testTopic, testNow); err == nil {
t.Fatal("oversized payload was accepted")
}
}
// Decode validates the message against both its parsed route and supplied clock.
func TestDecodeValidatesContract(t *testing.T) {
payload := []byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-002","occurred_at":"2026-10-13T12:00:00Z"}`)
if _, err := Decode[Heartbeat](payload, testTopic, testNow); err == nil {
t.Fatal("topic/body identity mismatch was accepted")
}
}
// The type parameter must implement MQTTMessage; successful decoding also validates it.
func TestDecodeAcceptsOnlyValidatedMQTTMessages(t *testing.T) {
payload := []byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"}`)
message, err := Decode[Heartbeat](payload, testTopic, testNow)
if err != nil {
t.Fatal(err)
}
if message.DeviceID != testTopic.DeviceID {
t.Fatalf("device_id = %q, want %q", message.DeviceID, testTopic.DeviceID)
}
}
// A body cannot override the device selected by the topic.
func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
topic, err := ParseTopic("lab/v1/devices/device-001/commands")
if err != nil {
t.Fatal(err)
}
command := Command{SchemaVersion: SchemaVersionOne, MessageID: "m-1", DeviceID: "device-002", OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: "c-1", Command: "ping"}
if err := command.Validate(topic, time.Now().UTC()); err == nil {
t.Fatal("topic/body identity mismatch was accepted")
}
}