package mqttplug import ( "context" "strings" "testing" mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/ccmpbll/printspy/models" ) func TestParseTopic(t *testing.T) { cases := []struct { in string topic, kind, suffix string ok bool }{ {"stat/testplug/POWER", "testplug", "stat", "POWER", true}, {"stat/testplug/POWER2", "testplug", "stat", "POWER2", true}, {"tele/testplug/SENSOR", "testplug", "tele", "SENSOR", true}, {"garbage", "", "", "", false}, } for _, c := range cases { topic, kind, suffix, ok := parseTopic(c.in) if ok != c.ok || topic != c.topic || kind != c.kind || suffix != c.suffix { t.Errorf("parseTopic(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)", c.in, topic, kind, suffix, ok, c.topic, c.kind, c.suffix, c.ok) } } } func TestCommandTopic(t *testing.T) { if got := commandTopic("testplug", "1"); got != "cmnd/testplug/Power" { t.Errorf("commandTopic idx=1 = %q, want cmnd/testplug/Power", got) } if got := commandTopic("testplug", "2"); got != "cmnd/testplug/Power2" { t.Errorf("commandTopic idx=2 = %q, want cmnd/testplug/Power2", got) } if got := commandTopic("testplug", ""); got != "cmnd/testplug/Power" { t.Errorf("commandTopic idx=\"\" = %q, want cmnd/testplug/Power", got) } } func TestApplyPowerAndEnergy(t *testing.T) { c := New() c.subs = map[string]topicSubs{ "testplug": {relays: map[string]relayMeta{"1": {Label: "Printer"}}}, } c.applyPower("testplug", "POWER", true) ps, ok := c.GetState("testplug", "1") if !ok || !ps.On || ps.Label != "Printer" || ps.ID != "mqtt:testplug:1" { t.Fatalf("applyPower: got %+v, ok=%v", ps, ok) } c.applyEnergy("testplug", []byte(`{"ENERGY":{"Power":12.3,"Voltage":120,"Current":0.1,"Total":0.5}}`)) ps, ok = c.GetState("testplug", "1") if !ok || ps.Watts != 12.3 || ps.Voltage != 120 || ps.Current != 0.1 || ps.TotalKWh != 0.5 { t.Fatalf("applyEnergy: got %+v", ps) } // On stays true - energy telemetry shouldn't clobber the last known // power state, they arrive on separate topics. if !ps.On { t.Errorf("On = false after applyEnergy, want true (unaffected)") } } func TestApplyPowerBareSuffixIsRelay1(t *testing.T) { c := New() c.applyPower("testplug", "POWER", true) if _, ok := c.GetState("testplug", "1"); !ok { t.Error("bare POWER suffix should key to idx \"1\"") } } func TestSyncDedupesByTopic(t *testing.T) { c := New() plugs := []models.SmartPlug{ {MQTTTopic: "shared", Idx: "1", Label: "A"}, {MQTTTopic: "shared", Idx: "2", Label: "B"}, {MQTTTopic: "other", Idx: "1", Label: "C"}, {MQTTTopic: "", IP: "1.2.3.4", Idx: "1", Label: "HTTP-mode, ignored"}, } c.Sync(plugs) if len(c.subs) != 2 { t.Fatalf("subs = %d topics, want 2", len(c.subs)) } if len(c.subs["shared"].relays) != 2 { t.Fatalf("shared topic relays = %d, want 2", len(c.subs["shared"].relays)) } } func TestSetCachedOnWinsOverStaleRead(t *testing.T) { // Regression: a poll cycle already in flight when SetState is called // reads whatever's cached - if that's the pre-command device echo // (device hasn't caught up yet), it must not win over the command we // just successfully published. c := New() c.subs = map[string]topicSubs{"testplug": {relays: map[string]relayMeta{"1": {Label: "Light"}}}} c.applyPower("testplug", "POWER", true) // device was last seen ON c.setCachedOn("testplug", "1", false) // we just commanded OFF ps, ok := c.GetState("testplug", "1") if !ok || ps.On { t.Fatalf("GetState after setCachedOn = %+v, ok=%v, want On=false", ps, ok) } } func TestGetStateUnknown(t *testing.T) { c := New() if _, ok := c.GetState("nope", "1"); ok { t.Error("GetState on unknown topic:idx should return ok=false") } } // fakePublishClient embeds the mqtt.Client interface (nil for every method // but Publish) so it satisfies the interface without stubbing methods // handleMessage never calls. type fakePublishClient struct { mqtt.Client published []string retained []bool } func (f *fakePublishClient) Publish(topic string, qos byte, retained bool, payload interface{}) mqtt.Token { f.published = append(f.published, topic) f.retained = append(f.retained, retained) return &fakeDoneToken{} } func (f *fakePublishClient) IsConnectionOpen() bool { return true } // fakeDoneToken is an already-completed mqtt.Token - Publish (mqttplug.go) // selects on Done()/ctx.Done(), so a nil token (fine for handleMessage's // fire-and-forget query publishes) would panic here. Embeds mqtt.Token (nil) // like fakePublishClient embeds mqtt.Client, overriding only what's called. type fakeDoneToken struct { mqtt.Token } func (t *fakeDoneToken) Done() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch } func (t *fakeDoneToken) Error() error { return nil } type fakeMessage struct { topic string payload []byte } func (m *fakeMessage) Duplicate() bool { return false } func (m *fakeMessage) Qos() byte { return 0 } func (m *fakeMessage) Retained() bool { return false } func (m *fakeMessage) Topic() string { return m.topic } func (m *fakeMessage) MessageID() uint16 { return 0 } func (m *fakeMessage) Payload() []byte { return m.payload } func (m *fakeMessage) Ack() {} func TestLWTOnlineTriggersRequery(t *testing.T) { // Regression: a device that reconnects to the broker (reboot, power // blip, wifi drop) may come back in a different state than we last // knew, with nothing else telling us - LWT flipping to Online is the // signal to ask again instead of trusting the stale cache. c := New() c.subs = map[string]topicSubs{"testplug": {relays: map[string]relayMeta{"1": {}}}} fc := &fakePublishClient{} c.handleMessage(fc, &fakeMessage{topic: "tele/testplug/LWT", payload: []byte("Online")}) if len(fc.published) != 1 || fc.published[0] != "cmnd/testplug/Power" { t.Fatalf("expected one Power query publish, got %v", fc.published) } } func TestLWTOfflineDoesNotRequery(t *testing.T) { c := New() c.subs = map[string]topicSubs{"testplug": {relays: map[string]relayMeta{"1": {}}}} fc := &fakePublishClient{} c.handleMessage(fc, &fakeMessage{topic: "tele/testplug/LWT", payload: []byte("Offline")}) if len(fc.published) != 0 { t.Fatalf("expected no publish for an Offline LWT, got %v", fc.published) } } func TestLWTOfflineMarksCacheOff(t *testing.T) { // Regression: a device that drops off the broker (most commonly lost // power, being an inline relay) must not leave the dashboard showing // its last confirmed reading forever - the cache has to reflect "off, // unconfirmed" instead of silently staying stuck on a stale "On". c := New() c.subs = map[string]topicSubs{"testplug": {relays: map[string]relayMeta{"1": {Label: "Printer"}}}} c.applyPower("testplug", "POWER", true) // device was last seen ON fc := &fakePublishClient{} c.handleMessage(fc, &fakeMessage{topic: "tele/testplug/LWT", payload: []byte("Offline")}) ps, ok := c.GetState("testplug", "1") if !ok || ps.On || ps.Source != "mqtt-offline" { t.Fatalf("GetState after Offline LWT = %+v, ok=%v, want On=false, Source=mqtt-offline", ps, ok) } } func TestPublishNotConfigured(t *testing.T) { c := New() if err := c.Publish(context.Background(), "printspy/printer/1/state", []byte("{}")); err == nil { t.Fatal("Publish with no configured client should error") } } func TestOnConnectPublishesAvailability(t *testing.T) { // Regression: onConnect must announce "online" itself - the broker's LWT // only ever fires "offline" (on connection loss), nothing publishes the // online side automatically. c := New() fc := &fakePublishClient{} c.onConnect(fc) if len(fc.published) != 1 || fc.published[0] != availabilityTopic { t.Fatalf("expected one publish to %s, got %v", availabilityTopic, fc.published) } if len(fc.retained) != 1 || !fc.retained[0] { t.Fatalf("expected availability publish to be retained, got %v", fc.retained) } } func TestPublish(t *testing.T) { c := New() fc := &fakePublishClient{} c.cli = fc payload := []byte(`{"id":1,"state":"printing"}`) if err := c.Publish(context.Background(), "printspy/printer/1/state", payload); err != nil { t.Fatalf("Publish returned error: %v", err) } if len(fc.published) != 1 || fc.published[0] != "printspy/printer/1/state" { t.Fatalf("expected one publish to printspy/printer/1/state, got %v", fc.published) } if len(fc.retained) != 1 || !fc.retained[0] { t.Fatalf("expected publish to be retained, got %v", fc.retained) } } func TestValidateBrokerURL(t *testing.T) { for in, ok := range map[string]bool{ "": true, "tcp://10.0.0.5:1883": true, "mqtts://broker.lan:8883": true, "wss://broker.lan/mqtt": true, "unix:///var/run/x.sock": false, "file:///etc/passwd": false, "10.0.0.5:1883": false, "tcp://": false, } { if err := ValidateBrokerURL(in); (err == nil) != ok { t.Errorf("ValidateBrokerURL(%q) = %v, want ok=%v", in, err, ok) } } } func TestRedactedBrokerHidesPassword(t *testing.T) { got := redactedBroker("tcp://user:s3cret@10.0.0.5:1883") if strings.Contains(got, "s3cret") { t.Errorf("redactedBroker leaked password: %q", got) } } // A bad new config must be rejected by the probe without touching the // working client - previously Configure tore the old one down first. func TestConfigureFailureKeepsExistingClient(t *testing.T) { c := New() old := &fakePublishClient{} c.cli = old if err := c.Configure("tcp://127.0.0.1:1", "", ""); err == nil { t.Fatal("want error for unreachable broker") } if c.cli != mqtt.Client(old) { t.Error("working client was replaced by a failed Configure") } if len(old.published) != 0 { t.Errorf("working client was told to go offline: %v", old.published) } }