package mqtt import ( "context" "encoding/json" "errors" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) func TestTopicHelpers(t *testing.T) { const config = "restrict/harry_phone_2/config" if got := StatusTopic(config); got != "restrict/harry_phone_2/status" { t.Errorf("StatusTopic = %q", got) } if got := TelemetryTopic(config); got != "restrict/harry_phone_2/telemetry" { t.Errorf("TelemetryTopic = %q", got) } for _, tc := range []struct { topic string suffix string want string ok bool }{ {"restrict/harry_phone_2/status", SuffixStatus, config, true}, {"restrict/harry_phone_2/telemetry", SuffixTelemetry, config, true}, {"restrict/harry_phone_2/config", SuffixStatus, "", false}, {"restrict/status", SuffixStatus, "", false}, {"restrict/a/b/status", SuffixStatus, "", false}, {"other/harry_phone_2/status", SuffixStatus, "", false}, } { got, ok := deviceFromTopic(tc.topic, tc.suffix) if ok != tc.ok || got != tc.want { t.Errorf("deviceFromTopic(%q, %q) = (%q, %v), want (%q, %v)", tc.topic, tc.suffix, got, ok, tc.want, tc.ok) } } } func TestConfigMarshal(t *testing.T) { until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC) b, err := Config{ V: ConfigVersion, DeviceID: 2, Name: "harry_phone", Platform: store.PlatformAndroid, Locked: true, Until: &until, GeneratedAt: time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC), }.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } // The lists must be present as arrays, never null, so a client can parse // this without null checks. if !strings.Contains(string(b), `"allow":[]`) || !strings.Contains(string(b), `"block":[]`) { t.Errorf("payload has null lists: %s", b) } var back Config if err := json.Unmarshal(b, &back); err != nil { t.Fatalf("Unmarshal: %v", err) } if !back.Locked || back.Until == nil || !back.Until.Equal(until) { t.Errorf("round trip lost the lock: %+v", back) } if back.V != ConfigVersion || back.DeviceID != 2 { t.Errorf("round trip lost the identity: %+v", back) } } func TestParseStatus(t *testing.T) { if _, err := ParseStatus(nil); err == nil { t.Error("expected an error for an empty payload") } if _, err := ParseStatus([]byte("{oops")); err == nil { t.Error("expected an error for malformed JSON") } // An explicit offline (the will payload the broker publishes on a dropped // connection) must read as offline. off, err := ParseStatus([]byte(`{"online":false}`)) if err != nil { t.Fatalf("ParseStatus: %v", err) } if off.IsOnline() { t.Error(`{"online":false} read as online`) } // A report with no "online" field means the device is talking. on, err := ParseStatus([]byte(`{"app":"com.spotify.music"}`)) if err != nil { t.Fatalf("ParseStatus: %v", err) } if !on.IsOnline() { t.Error("a report with no online field read as offline") } if on.App != "com.spotify.music" { t.Errorf("App = %q", on.App) } at := on.Timestamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) if !at.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) { t.Errorf("Timestamp with no at field = %v, want the fallback", at) } at = Status{At: "2026-10-13T07:30:00Z"}.Timestamp(time.Now()) if at.UTC().Format(time.RFC3339) != "2026-10-13T07:30:00Z" { t.Errorf("Timestamp = %v", at) } } func TestTelemetryRows(t *testing.T) { payload := []byte(`{ "day": "2026-10-13", "apps": [{"name": "com.spotify.music", "seconds": 600}, {"name": "", "seconds": 5}], "domains": [{"name": "youtube.com", "seconds": 120}] }`) report, err := ParseTelemetry(payload) if err != nil { t.Fatalf("ParseTelemetry: %v", err) } rows := report.Rows() if len(rows) != 2 { t.Fatalf("Rows = %+v, want 2 rows", rows) } // Apps first, then domains. if rows[0].App != "com.spotify.music" || rows[0].Seconds != 600 { t.Errorf("rows[0] = %+v", rows[0]) } if rows[1].Domain != "youtube.com" || rows[1].Seconds != 120 { t.Errorf("rows[1] = %+v", rows[1]) } if got := report.DayOrDefault(time.Now()); got != "2026-10-13" { t.Errorf("DayOrDefault = %q, want the reported day", got) } // The flat single-row form, and a bad day falling back to today. flat, err := ParseTelemetry([]byte(`{"app":"roblox","seconds":60,"day":"yesterday"}`)) if err != nil { t.Fatalf("ParseTelemetry flat: %v", err) } flatRows := flat.Rows() if len(flatRows) != 1 || flatRows[0].App != "roblox" { t.Fatalf("flat Rows = %+v", flatRows) } now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) if got := flat.DayOrDefault(now); got != "2026-03-04" { t.Errorf("DayOrDefault with a bad day = %q, want 2026-03-04", got) } // Negative totals are dropped, not stored. bad, err := ParseTelemetry([]byte(`{"apps":[{"name":"x","seconds":-1}]}`)) if err != nil { t.Fatalf("ParseTelemetry: %v", err) } if len(bad.Rows()) != 0 { t.Errorf("negative usage stored: %+v", bad.Rows()) } if _, err := ParseTelemetry([]byte("not json")); err == nil { t.Error("expected an error for malformed JSON") } } func TestNewRejectsIncompleteOptions(t *testing.T) { if _, err := New(Options{Broker: "tcp://127.0.0.1:1883"}); err == nil { t.Error("expected an error without a device lookup, source and sink") } _, err := New(Options{ Broker: "tls://broker:8883", CAFile: "/nonexistent/ca.crt", Devices: stubLookup{}, Source: stubSource{}, Reports: stubSink{}, }) if err == nil { t.Fatal("expected an error for a missing CA bundle") } if !strings.Contains(err.Error(), "ca.crt") { t.Errorf("error = %v, want it to name the CA file", err) } } type stubLookup struct{} func (stubLookup) DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error) { return nil, errors.New("not implemented") } type stubSource struct{} func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil } func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) { return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil } type stubSink struct{} func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) { return false, nil } func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) { return 0, nil } // stateSource returns a fixed effective state, to prove the published payload // without a broker. type stateSource struct { eff store.EffectiveState } func (s stateSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil } func (s stateSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) { eff := s.eff eff.DeviceID = dev.ID return eff, nil } // TestBuildConfigAllowlistIsolation is the Phase 6 definition of done: with // Spotify allowlisted while other apps are blocked during an active schedule // window, the retained payload says Spotify is allowed and the others are // blocked. func TestBuildConfigAllowlistIsolation(t *testing.T) { until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC) link, err := New(Options{ Broker: "tcp://127.0.0.1:1883", Source: stateSource{eff: store.EffectiveState{ Locked: true, Until: &until, Reason: "schedule", Allow: []string{"com.spotify.music"}, Block: []string{"com.youtube.android", "roblox.exe"}, }}, Devices: stubLookup{}, Reports: stubSink{}, }) if err != nil { t.Fatalf("New: %v", err) } payload, err := link.buildConfig(context.Background(), store.Device{ ID: 7, Name: "Harry's phone", Platform: store.PlatformAndroid, }) if err != nil { t.Fatalf("buildConfig: %v", err) } var cfg Config if err := json.Unmarshal(payload, &cfg); err != nil { t.Fatalf("Unmarshal: %v", err) } if !cfg.Locked || cfg.Reason != "schedule" { t.Errorf("payload = %s, want locked by schedule", payload) } if cfg.Until == nil || !cfg.Until.Equal(until) { t.Errorf("until = %v, want %v", cfg.Until, until) } if len(cfg.Allow) != 1 || cfg.Allow[0] != "com.spotify.music" { t.Errorf("allow = %v, want Spotify allowed", cfg.Allow) } if len(cfg.Block) != 2 { t.Errorf("block = %v, want the other apps blocked", cfg.Block) } // The JSON is the contract the kids' clients read. for _, want := range []string{ `"locked":true`, `"reason":"schedule"`, `"allow":["com.spotify.music"]`, } { if !strings.Contains(string(payload), want) { t.Errorf("payload %s missing %s", payload, want) } } } func TestParseStatusTamper(t *testing.T) { payload := []byte(`{"online":true,"app":"com.android.settings",` + `"tamper":{"kind":"accessibility_disabled","detail":"service turned off",` + `"at":"2026-10-13T08:00:00Z"},"at":"2026-10-13T08:00:01Z"}`) st, err := ParseStatus(payload) if err != nil { t.Fatalf("ParseStatus: %v", err) } if !st.HasTamper() { t.Fatal("HasTamper = false, want true") } if st.Tamper.Kind != "accessibility_disabled" { t.Errorf("kind = %q", st.Tamper.Kind) } if st.Tamper.Detail != "service turned off" { t.Errorf("detail = %q", st.Tamper.Detail) } plain, err := ParseStatus([]byte(`{"online":false}`)) if err != nil { t.Fatalf("ParseStatus plain: %v", err) } if plain.HasTamper() { t.Error("plain status reported a tamper") } }