Files
meshcore-analyzer/cmd/ingestor/config_test.go
T
Kpa-clawbot b0e4d2fa18 feat: add optional MQTT region field (#788) (#1012)
## Summary

Add optional `region` field to MQTT source config and JSON payload,
enabling publishers to explicitly provide region data without relying
solely on topic path structure.

## Changes

- **`MQTTSource.Region`** — new optional config field. When set, acts as
default region for all messages from that source (useful when a broker
serves a single region).
- **`MQTTPacketMessage.Region`** — new optional JSON payload field.
Publishers can include `"region": "PDX"` in their MQTT messages.
- **`PacketData.Region`** — carries the resolved region through to
storage.
- **Priority resolution**: payload `region` > topic-derived region >
source config `region`
- Observer IATA is updated with the effective region on every packet.

## Config example

```json
{
  "mqttSources": [
    {
      "name": "cascadia",
      "broker": "tcp://cascadia-broker:1883",
      "topics": ["meshcore/#"],
      "region": "PDX"
    }
  ]
}
```

## Payload example

```json
{"raw": "0a1b2c...", "SNR": 5.2, "region": "PDX"}
```

## TDD

- Red commit: `980304c` (tests fail at compile — fields don't exist)
- Green commit: `4caf88b` (implementation, all tests pass)

## Unblocks

- #804, #770, #730 (all depend on region being available on
observations)

Fixes #788

---------

Co-authored-by: you <you@example.com>
2026-05-03 11:21:54 -07:00

397 lines
10 KiB
Go

package main
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigValidJSON(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "/tmp/test.db",
"mqttSources": [
{"name": "s1", "broker": "tcp://localhost:1883", "topics": ["meshcore/#"]}
]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.DBPath != "/tmp/test.db" {
t.Errorf("dbPath=%s, want /tmp/test.db", cfg.DBPath)
}
if len(cfg.MQTTSources) != 1 {
t.Fatalf("mqttSources len=%d, want 1", len(cfg.MQTTSources))
}
if cfg.MQTTSources[0].Broker != "tcp://localhost:1883" {
t.Errorf("broker=%s", cfg.MQTTSources[0].Broker)
}
}
func TestLoadConfigMissingFile(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
cfg, err := LoadConfig("/nonexistent/path/config.json")
if err != nil {
t.Fatalf("missing config should not error (zero-config mode), got: %v", err)
}
if cfg.DBPath != "data/meshcore.db" {
t.Errorf("dbPath=%s, want data/meshcore.db", cfg.DBPath)
}
// Should default to localhost MQTT
if len(cfg.MQTTSources) != 1 {
t.Fatalf("mqttSources len=%d, want 1", len(cfg.MQTTSources))
}
if cfg.MQTTSources[0].Broker != "mqtt://localhost:1883" {
t.Errorf("default broker=%s, want mqtt://localhost:1883", cfg.MQTTSources[0].Broker)
}
if cfg.MQTTSources[0].Name != "local" {
t.Errorf("default source name=%s, want local", cfg.MQTTSources[0].Name)
}
}
func TestLoadConfigMalformedJSON(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "bad.json")
os.WriteFile(cfgPath, []byte(`{not valid json`), 0o644)
_, err := LoadConfig(cfgPath)
if err == nil {
t.Error("expected error for malformed JSON")
}
}
func TestLoadConfigEnvVarDBPath(t *testing.T) {
t.Setenv("DB_PATH", "/override/db.sqlite")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{"dbPath": "original.db"}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.DBPath != "/override/db.sqlite" {
t.Errorf("dbPath=%s, want /override/db.sqlite", cfg.DBPath)
}
}
func TestLoadConfigEnvVarMQTTBroker(t *testing.T) {
t.Setenv("MQTT_BROKER", "tcp://env-broker:1883")
t.Setenv("MQTT_TOPIC", "custom/topic")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{"dbPath": "test.db"}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if len(cfg.MQTTSources) != 1 {
t.Fatalf("mqttSources len=%d, want 1", len(cfg.MQTTSources))
}
src := cfg.MQTTSources[0]
if src.Name != "env" {
t.Errorf("name=%s, want env", src.Name)
}
if src.Broker != "tcp://env-broker:1883" {
t.Errorf("broker=%s", src.Broker)
}
if len(src.Topics) != 1 || src.Topics[0] != "custom/topic" {
t.Errorf("topics=%v, want [custom/topic]", src.Topics)
}
}
func TestLoadConfigEnvVarMQTTBrokerDefaultTopic(t *testing.T) {
t.Setenv("MQTT_BROKER", "tcp://env-broker:1883")
t.Setenv("MQTT_TOPIC", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{"dbPath": "test.db"}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.MQTTSources[0].Topics[0] != "meshcore/#" {
t.Errorf("default topic=%s, want meshcore/#", cfg.MQTTSources[0].Topics[0])
}
}
func TestLoadConfigLegacyMQTT(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "test.db",
"mqtt": {"broker": "tcp://legacy:1883", "topic": "old/topic"}
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if len(cfg.MQTTSources) != 1 {
t.Fatalf("mqttSources len=%d, want 1", len(cfg.MQTTSources))
}
src := cfg.MQTTSources[0]
if src.Name != "default" {
t.Errorf("name=%s, want default", src.Name)
}
if src.Broker != "tcp://legacy:1883" {
t.Errorf("broker=%s", src.Broker)
}
if len(src.Topics) != 2 || src.Topics[0] != "old/topic" || src.Topics[1] != "meshcore/#" {
t.Errorf("topics=%v, want [old/topic meshcore/#]", src.Topics)
}
}
func TestLoadConfigLegacyMQTTNotUsedWhenSourcesExist(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "test.db",
"mqtt": {"broker": "tcp://legacy:1883", "topic": "old/topic"},
"mqttSources": [{"name": "modern", "broker": "tcp://modern:1883", "topics": ["m/#"]}]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if len(cfg.MQTTSources) != 1 {
t.Fatalf("mqttSources len=%d, want 1", len(cfg.MQTTSources))
}
if cfg.MQTTSources[0].Name != "modern" {
t.Errorf("should use modern source, got name=%s", cfg.MQTTSources[0].Name)
}
}
func TestLoadConfigDefaultDBPath(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.DBPath != "data/meshcore.db" {
t.Errorf("dbPath=%s, want data/meshcore.db", cfg.DBPath)
}
}
func TestLoadConfigLegacyMQTTEmptyBroker(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "test.db",
"mqtt": {"broker": "", "topic": "t"}
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if len(cfg.MQTTSources) != 1 || cfg.MQTTSources[0].Name != "local" {
t.Errorf("mqttSources should default to local broker when legacy broker is empty, got %v", cfg.MQTTSources)
}
}
func TestResolvedSources(t *testing.T) {
cfg := &Config{
MQTTSources: []MQTTSource{
{Name: "a", Broker: "tcp://a:1883"},
{Name: "b", Broker: "tcp://b:1883"},
},
}
sources := cfg.ResolvedSources()
if len(sources) != 2 {
t.Fatalf("len=%d, want 2", len(sources))
}
if sources[0].Name != "a" || sources[1].Name != "b" {
t.Errorf("sources=%v", sources)
}
}
func TestResolvedSourcesEmpty(t *testing.T) {
cfg := &Config{}
sources := cfg.ResolvedSources()
if len(sources) != 0 {
t.Errorf("len=%d, want 0", len(sources))
}
}
func TestLoadConfigWithAllFields(t *testing.T) {
t.Setenv("DB_PATH", "")
t.Setenv("MQTT_BROKER", "")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
reject := false
_ = reject
os.WriteFile(cfgPath, []byte(`{
"dbPath": "mydb.db",
"logLevel": "debug",
"mqttSources": [{
"name": "full",
"broker": "tcp://full:1883",
"username": "user1",
"password": "pass1",
"rejectUnauthorized": false,
"topics": ["a/#", "b/#"],
"iataFilter": ["SJC", "LAX"]
}]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.LogLevel != "debug" {
t.Errorf("logLevel=%s, want debug", cfg.LogLevel)
}
src := cfg.MQTTSources[0]
if src.Username != "user1" {
t.Errorf("username=%s", src.Username)
}
if src.Password != "pass1" {
t.Errorf("password=%s", src.Password)
}
if src.RejectUnauthorized == nil || *src.RejectUnauthorized != false {
t.Error("rejectUnauthorized should be false")
}
if len(src.IATAFilter) != 2 || src.IATAFilter[0] != "SJC" {
t.Errorf("iataFilter=%v", src.IATAFilter)
}
}
func TestConnectTimeoutOrDefault(t *testing.T) {
// Default when unset
s := MQTTSource{}
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("default: got %d, want 30", got)
}
// Custom value
s.ConnectTimeoutSec = 5
if got := s.ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("custom: got %d, want 5", got)
}
// Zero treated as unset
s.ConnectTimeoutSec = 0
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("zero: got %d, want 30", got)
}
}
func TestConnectTimeoutFromJSON(t *testing.T) {
dir := t.TempDir()
cfgPath := dir + "/config.json"
os.WriteFile(cfgPath, []byte(`{"mqttSources":[{"name":"s1","broker":"tcp://b:1883","topics":["#"],"connectTimeoutSec":5}]}`), 0644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if got := cfg.MQTTSources[0].ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("from JSON: got %d, want 5", got)
}
}
func TestObserverIATAWhitelist(t *testing.T) {
// Config with whitelist set
cfg := Config{
ObserverIATAWhitelist: []string{"ARN", "got"},
}
// Matching (case-insensitive)
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed")
}
if !cfg.IsObserverIATAAllowed("arn") {
t.Error("arn (lowercase) should be allowed")
}
if !cfg.IsObserverIATAAllowed("GOT") {
t.Error("GOT should be allowed")
}
// Non-matching
if cfg.IsObserverIATAAllowed("SJC") {
t.Error("SJC should NOT be allowed")
}
// Empty string not allowed
if cfg.IsObserverIATAAllowed("") {
t.Error("empty IATA should NOT be allowed")
}
}
func TestObserverIATAWhitelistEmpty(t *testing.T) {
// No whitelist = allow all
cfg := Config{}
if !cfg.IsObserverIATAAllowed("SJC") {
t.Error("with no whitelist, all IATAs should be allowed")
}
if !cfg.IsObserverIATAAllowed("") {
t.Error("with no whitelist, even empty IATA should be allowed")
}
}
func TestObserverIATAWhitelistJSON(t *testing.T) {
json := `{
"dbPath": "test.db",
"observerIATAWhitelist": ["ARN", "GOT"]
}`
tmp := t.TempDir() + "/config.json"
os.WriteFile(tmp, []byte(json), 0644)
cfg, err := LoadConfig(tmp)
if err != nil {
t.Fatal(err)
}
if len(cfg.ObserverIATAWhitelist) != 2 {
t.Fatalf("expected 2 entries, got %d", len(cfg.ObserverIATAWhitelist))
}
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed after loading from JSON")
}
}
func TestMQTTSourceRegionField(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "/tmp/test.db",
"mqttSources": [
{"name": "cascadia", "broker": "tcp://localhost:1883", "topics": ["meshcore/#"], "region": "PDX"}
]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.MQTTSources[0].Region != "PDX" {
t.Fatalf("expected region PDX, got %q", cfg.MQTTSources[0].Region)
}
}