diff --git a/cmd/ingestor/config_test.go b/cmd/ingestor/config_test.go new file mode 100644 index 00000000..a9651fb9 --- /dev/null +++ b/cmd/ingestor/config_test.go @@ -0,0 +1,270 @@ +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) { + _, err := LoadConfig("/nonexistent/path/config.json") + if err == nil { + t.Error("expected error for missing file") + } +} + +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) != 0 { + t.Errorf("mqttSources should be empty when legacy broker is empty, got %d", len(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) + } +} diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index cd700919..a2ca50d1 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -250,6 +250,319 @@ func TestEndToEndIngest(t *testing.T) { } } +func TestInsertTransmissionEmptyHash(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + data := &PacketData{ + RawHex: "0A00", + Timestamp: "2026-03-25T00:00:00Z", + Hash: "", // empty hash → should return nil + } + err = s.InsertTransmission(data) + if err != nil { + t.Errorf("empty hash should return nil, got %v", err) + } + + var count int + s.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Errorf("no transmission should be inserted for empty hash, got count=%d", count) + } +} + +func TestInsertTransmissionEmptyTimestamp(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "", // empty → uses current time + Hash: "emptyts123456789", + RouteType: 2, + } + err = s.InsertTransmission(data) + if err != nil { + t.Fatal(err) + } + + var firstSeen string + s.db.QueryRow("SELECT first_seen FROM transmissions WHERE hash = ?", data.Hash).Scan(&firstSeen) + if firstSeen == "" { + t.Error("first_seen should be set even with empty timestamp") + } +} + +func TestInsertTransmissionEarlierFirstSeen(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + // Insert with later timestamp + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T12:00:00Z", + Hash: "firstseen12345678", + RouteType: 2, + } + if err := s.InsertTransmission(data); err != nil { + t.Fatal(err) + } + + // Insert again with earlier timestamp + data2 := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T06:00:00Z", // earlier + Hash: "firstseen12345678", // same hash + RouteType: 2, + } + if err := s.InsertTransmission(data2); err != nil { + t.Fatal(err) + } + + var firstSeen string + s.db.QueryRow("SELECT first_seen FROM transmissions WHERE hash = ?", data.Hash).Scan(&firstSeen) + if firstSeen != "2026-03-25T06:00:00Z" { + t.Errorf("first_seen=%s, want 2026-03-25T06:00:00Z (earlier timestamp)", firstSeen) + } +} + +func TestInsertTransmissionLaterFirstSeenNotUpdated(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T06:00:00Z", + Hash: "notupdated1234567", + RouteType: 2, + } + if err := s.InsertTransmission(data); err != nil { + t.Fatal(err) + } + + // Insert with later timestamp — should NOT update first_seen + data2 := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T18:00:00Z", + Hash: "notupdated1234567", + RouteType: 2, + } + if err := s.InsertTransmission(data2); err != nil { + t.Fatal(err) + } + + var firstSeen string + s.db.QueryRow("SELECT first_seen FROM transmissions WHERE hash = ?", data.Hash).Scan(&firstSeen) + if firstSeen != "2026-03-25T06:00:00Z" { + t.Errorf("first_seen=%s should not change to later time", firstSeen) + } +} + +func TestInsertTransmissionNilSNRRSSI(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T00:00:00Z", + Hash: "nilsnrrssi1234567", + RouteType: 2, + SNR: nil, + RSSI: nil, + } + err = s.InsertTransmission(data) + if err != nil { + t.Fatal(err) + } + + var snr, rssi *float64 + s.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi) + if snr != nil { + t.Errorf("snr should be nil, got %v", snr) + } + if rssi != nil { + t.Errorf("rssi should be nil, got %v", rssi) + } +} + +func TestBuildPacketData(t *testing.T) { + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + decoded, err := DecodePacket(rawHex) + if err != nil { + t.Fatal(err) + } + + snr := 5.0 + rssi := -100.0 + msg := &MQTTPacketMessage{ + Raw: rawHex, + SNR: &snr, + RSSI: &rssi, + Origin: "test-observer", + } + + pkt := BuildPacketData(msg, decoded, "obs123", "SJC") + + if pkt.RawHex != rawHex { + t.Errorf("rawHex mismatch") + } + if pkt.ObserverID != "obs123" { + t.Errorf("observerID=%s, want obs123", pkt.ObserverID) + } + if pkt.ObserverName != "test-observer" { + t.Errorf("observerName=%s", pkt.ObserverName) + } + if pkt.SNR == nil || *pkt.SNR != 5.0 { + t.Errorf("SNR=%v", pkt.SNR) + } + if pkt.RSSI == nil || *pkt.RSSI != -100.0 { + t.Errorf("RSSI=%v", pkt.RSSI) + } + if pkt.Hash == "" { + t.Error("hash should not be empty") + } + if len(pkt.Hash) != 16 { + t.Errorf("hash length=%d, want 16", len(pkt.Hash)) + } + if pkt.RouteType != decoded.Header.RouteType { + t.Errorf("routeType mismatch") + } + if pkt.PayloadType != decoded.Header.PayloadType { + t.Errorf("payloadType mismatch") + } + if pkt.Timestamp == "" { + t.Error("timestamp should be set") + } + if pkt.DecodedJSON == "" || pkt.DecodedJSON == "{}" { + t.Error("decodedJSON should be populated") + } +} + +func TestBuildPacketDataWithHops(t *testing.T) { + // A packet with actual hops in the path + raw := "0505AABBCCDDEE" + strings.Repeat("00", 10) + decoded, err := DecodePacket(raw) + if err != nil { + t.Fatal(err) + } + msg := &MQTTPacketMessage{Raw: raw} + pkt := BuildPacketData(msg, decoded, "", "") + + if pkt.PathJSON == "[]" { + t.Error("pathJSON should contain hops") + } + if !strings.Contains(pkt.PathJSON, "AA") { + t.Errorf("pathJSON should contain hop AA: %s", pkt.PathJSON) + } +} + +func TestBuildPacketDataNilSNRRSSI(t *testing.T) { + decoded, _ := DecodePacket("0A00" + strings.Repeat("00", 10)) + msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)} + pkt := BuildPacketData(msg, decoded, "", "") + + if pkt.SNR != nil { + t.Errorf("SNR should be nil") + } + if pkt.RSSI != nil { + t.Errorf("RSSI should be nil") + } +} + +func TestUpsertNodeEmptyLastSeen(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + lat := 37.0 + lon := -122.0 + // Empty lastSeen → should use current time + if err := s.UpsertNode("aabbccdd", "TestNode", "repeater", &lat, &lon, ""); err != nil { + t.Fatal(err) + } + + var lastSeen string + s.db.QueryRow("SELECT last_seen FROM nodes WHERE public_key = 'aabbccdd'").Scan(&lastSeen) + if lastSeen == "" { + t.Error("last_seen should be set even with empty input") + } +} + +func TestOpenStoreTwice(t *testing.T) { + // Opening same DB twice tests the "observations already exists" path in applySchema + path := tempDBPath(t) + s1, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + s1.Close() + + // Second open — observations table already exists + s2, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + + // Verify it still works + var count int + s2.db.QueryRow("SELECT COUNT(*) FROM observations").Scan(&count) + if count != 0 { + t.Errorf("expected 0 observations, got %d", count) + } +} + +func TestInsertTransmissionDedupObservation(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + // First insert + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-03-25T00:00:00Z", + Hash: "dedupobs12345678", + RouteType: 2, + PathJSON: "[]", + } + if err := s.InsertTransmission(data); err != nil { + t.Fatal(err) + } + + // Insert same hash again with same observer (no observerID) — + // the UNIQUE constraint on observations dedup should handle it + if err := s.InsertTransmission(data); err != nil { + t.Fatal(err) + } + + var count int + s.db.QueryRow("SELECT COUNT(*) FROM observations").Scan(&count) + // Should have 2 observations (no observer_idx means both have NULL) + // Actually INSERT OR IGNORE — may be 1 due to dedup index + if count < 1 { + t.Errorf("should have at least 1 observation, got %d", count) + } +} + func TestSchemaCompatibility(t *testing.T) { s, err := OpenStore(tempDBPath(t)) if err != nil { diff --git a/cmd/ingestor/decoder_test.go b/cmd/ingestor/decoder_test.go index 9a02fbce..1558843f 100644 --- a/cmd/ingestor/decoder_test.go +++ b/cmd/ingestor/decoder_test.go @@ -437,6 +437,564 @@ func TestValidateAdvert(t *testing.T) { } } +func TestDecodeGrpTxtShort(t *testing.T) { + p := decodeGrpTxt([]byte{0x01, 0x02}) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "GRP_TXT" { + t.Errorf("type=%s, want GRP_TXT", p.Type) + } +} + +func TestDecodeGrpTxtValid(t *testing.T) { + p := decodeGrpTxt([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE}) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.ChannelHash != 0xAA { + t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash) + } + if p.MAC != "bbcc" { + t.Errorf("mac=%s, want bbcc", p.MAC) + } + if p.EncryptedData != "ddee" { + t.Errorf("encryptedData=%s, want ddee", p.EncryptedData) + } +} + +func TestDecodeAnonReqShort(t *testing.T) { + p := decodeAnonReq(make([]byte, 10)) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "ANON_REQ" { + t.Errorf("type=%s, want ANON_REQ", p.Type) + } +} + +func TestDecodeAnonReqValid(t *testing.T) { + buf := make([]byte, 40) + buf[0] = 0xFF // destHash + for i := 1; i < 33; i++ { + buf[i] = byte(i) + } + buf[33] = 0xAA + buf[34] = 0xBB + p := decodeAnonReq(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "ff" { + t.Errorf("destHash=%s, want ff", p.DestHash) + } + if p.MAC != "aabb" { + t.Errorf("mac=%s, want aabb", p.MAC) + } +} + +func TestDecodePathPayloadShort(t *testing.T) { + p := decodePathPayload([]byte{0x01, 0x02, 0x03}) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "PATH" { + t.Errorf("type=%s, want PATH", p.Type) + } +} + +func TestDecodePathPayloadValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + p := decodePathPayload(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "aa" { + t.Errorf("destHash=%s, want aa", p.DestHash) + } + if p.SrcHash != "bb" { + t.Errorf("srcHash=%s, want bb", p.SrcHash) + } + if p.PathData != "eeff" { + t.Errorf("pathData=%s, want eeff", p.PathData) + } +} + +func TestDecodeTraceShort(t *testing.T) { + p := decodeTrace(make([]byte, 5)) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "TRACE" { + t.Errorf("type=%s, want TRACE", p.Type) + } +} + +func TestDecodeTraceValid(t *testing.T) { + buf := make([]byte, 16) + buf[0] = 0x00 + buf[1] = 0x01 // tag LE uint32 = 1 + buf[5] = 0xAA // destHash start + buf[11] = 0xBB + p := decodeTrace(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.Tag != 1 { + t.Errorf("tag=%d, want 1", p.Tag) + } + if p.Type != "TRACE" { + t.Errorf("type=%s, want TRACE", p.Type) + } +} + +func TestDecodeAdvertShort(t *testing.T) { + p := decodeAdvert(make([]byte, 50)) + if p.Error != "too short for advert" { + t.Errorf("expected 'too short for advert' error, got %q", p.Error) + } +} + +func TestDecodeEncryptedPayloadShort(t *testing.T) { + p := decodeEncryptedPayload("REQ", []byte{0x01, 0x02}) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "REQ" { + t.Errorf("type=%s, want REQ", p.Type) + } +} + +func TestDecodeEncryptedPayloadValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + p := decodeEncryptedPayload("RESPONSE", buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "aa" { + t.Errorf("destHash=%s, want aa", p.DestHash) + } + if p.SrcHash != "bb" { + t.Errorf("srcHash=%s, want bb", p.SrcHash) + } + if p.MAC != "ccdd" { + t.Errorf("mac=%s, want ccdd", p.MAC) + } + if p.EncryptedData != "eeff" { + t.Errorf("encryptedData=%s, want eeff", p.EncryptedData) + } +} + +func TestDecodePayloadGRPData(t *testing.T) { + buf := []byte{0x01, 0x02, 0x03} + p := decodePayload(PayloadGRP_DATA, buf) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } + if p.RawHex != "010203" { + t.Errorf("rawHex=%s, want 010203", p.RawHex) + } +} + +func TestDecodePayloadRAWCustom(t *testing.T) { + buf := []byte{0xFF, 0xFE} + p := decodePayload(PayloadRAW_CUSTOM, buf) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadAllTypes(t *testing.T) { + // REQ + p := decodePayload(PayloadREQ, make([]byte, 10)) + if p.Type != "REQ" { + t.Errorf("REQ: type=%s", p.Type) + } + + // RESPONSE + p = decodePayload(PayloadRESPONSE, make([]byte, 10)) + if p.Type != "RESPONSE" { + t.Errorf("RESPONSE: type=%s", p.Type) + } + + // TXT_MSG + p = decodePayload(PayloadTXT_MSG, make([]byte, 10)) + if p.Type != "TXT_MSG" { + t.Errorf("TXT_MSG: type=%s", p.Type) + } + + // ACK + p = decodePayload(PayloadACK, make([]byte, 10)) + if p.Type != "ACK" { + t.Errorf("ACK: type=%s", p.Type) + } + + // GRP_TXT + p = decodePayload(PayloadGRP_TXT, make([]byte, 10)) + if p.Type != "GRP_TXT" { + t.Errorf("GRP_TXT: type=%s", p.Type) + } + + // ANON_REQ + p = decodePayload(PayloadANON_REQ, make([]byte, 40)) + if p.Type != "ANON_REQ" { + t.Errorf("ANON_REQ: type=%s", p.Type) + } + + // PATH + p = decodePayload(PayloadPATH, make([]byte, 10)) + if p.Type != "PATH" { + t.Errorf("PATH: type=%s", p.Type) + } + + // TRACE + p = decodePayload(PayloadTRACE, make([]byte, 20)) + if p.Type != "TRACE" { + t.Errorf("TRACE: type=%s", p.Type) + } +} + +func TestPayloadJSON(t *testing.T) { + p := &Payload{Type: "TEST", Name: "hello"} + j := PayloadJSON(p) + if j == "" || j == "{}" { + t.Errorf("PayloadJSON returned empty: %s", j) + } + if !strings.Contains(j, `"type":"TEST"`) { + t.Errorf("PayloadJSON missing type: %s", j) + } + if !strings.Contains(j, `"name":"hello"`) { + t.Errorf("PayloadJSON missing name: %s", j) + } +} + +func TestPayloadJSONNil(t *testing.T) { + // nil should not panic + j := PayloadJSON(nil) + if j != "null" && j != "{}" { + // json.Marshal(nil) returns "null" + t.Logf("PayloadJSON(nil) = %s", j) + } +} + +func TestValidateAdvertNaNLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + nanVal := math.NaN() + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &nanVal}) + if ok { + t.Error("NaN lat should fail") + } + if !strings.Contains(reason, "lat") { + t.Errorf("reason should mention lat: %s", reason) + } +} + +func TestValidateAdvertInfLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + infVal := math.Inf(1) + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &infVal}) + if ok { + t.Error("Inf lon should fail") + } + if !strings.Contains(reason, "lon") { + t.Errorf("reason should mention lon: %s", reason) + } +} + +func TestValidateAdvertNegInfLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + negInf := math.Inf(-1) + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &negInf}) + if ok { + t.Error("-Inf lat should fail") + } +} + +func TestValidateAdvertNaNLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + nan := math.NaN() + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &nan}) + if ok { + t.Error("NaN lon should fail") + } +} + +func TestValidateAdvertControlChars(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + tests := []struct { + name string + char string + }{ + {"null", "\x00"}, + {"bell", "\x07"}, + {"backspace", "\x08"}, + {"vtab", "\x0b"}, + {"formfeed", "\x0c"}, + {"shift out", "\x0e"}, + {"unit sep", "\x1f"}, + {"delete", "\x7f"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Name: "test" + tt.char + "name"}) + if ok { + t.Errorf("control char %q in name should fail", tt.char) + } + }) + } +} + +func TestValidateAdvertAllowedCharsInName(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // Tab (\t = 0x09), newline (\n = 0x0a), carriage return (\r = 0x0d) are NOT blocked + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Name: "hello\tworld", Flags: &AdvertFlags{Repeater: true}}) + if !ok { + t.Errorf("tab in name should be allowed, got reason: %s", reason) + } +} + +func TestValidateAdvertUnknownRole(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // type=0 maps to companion via Chat=false, Repeater=false, Room=false, Sensor=false → companion + // type=5 (unknown) → companion (default), which IS a valid role + // But if all booleans are false AND type is 0, advertRole returns "companion" which is valid + // To get "unknown", we'd need a flags combo that doesn't match any valid role + // Actually advertRole always returns companion as default — so let's just test the validation path + flags := &AdvertFlags{Type: 5, Chat: false, Repeater: false, Room: false, Sensor: false} + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Flags: flags}) + // advertRole returns "companion" for this, which is valid + if !ok { + t.Errorf("default companion role should be valid, got: %s", reason) + } +} + +func TestValidateAdvertValidLocation(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + lat := 45.0 + lon := -90.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat, Lon: &lon, Flags: &AdvertFlags{Repeater: true}}) + if !ok { + t.Error("valid lat/lon should pass") + } +} + +func TestValidateAdvertBoundaryLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // Exactly at boundary + lat90 := 90.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat90}) + if !ok { + t.Error("lat=90 should pass") + } + latNeg90 := -90.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &latNeg90}) + if !ok { + t.Error("lat=-90 should pass") + } + // Just over + lat91 := 90.001 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat91}) + if ok { + t.Error("lat=90.001 should fail") + } +} + +func TestValidateAdvertBoundaryLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + lon180 := 180.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lon180}) + if !ok { + t.Error("lon=180 should pass") + } + lonNeg180 := -180.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lonNeg180}) + if !ok { + t.Error("lon=-180 should pass") + } +} + +func TestComputeContentHashShortHex(t *testing.T) { + // Less than 16 hex chars and invalid hex + hash := ComputeContentHash("AB") + if hash != "AB" { + t.Errorf("short hex hash=%s, want AB", hash) + } + + // Exactly 16 chars invalid hex + hash = ComputeContentHash("ZZZZZZZZZZZZZZZZ") + if len(hash) != 16 { + t.Errorf("invalid hex hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashTransportRoute(t *testing.T) { + // Route type 0 (TRANSPORT_FLOOD) with no path hops + 4 transport code bytes + // header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops) + // transport codes = 4 bytes, then payload + hex := "1400" + "AABBCCDD" + strings.Repeat("EE", 10) + hash := ComputeContentHash(hex) + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashPayloadBeyondBuffer(t *testing.T) { + // path claims more bytes than buffer has → fallback + // header=0x05 (FLOOD, REQ), pathByte=0x3F (63 hops of 1 byte = 63 path bytes) + // but total buffer is only 4 bytes + hex := "053F" + "AABB" + hash := ComputeContentHash(hex) + // payloadStart = 2 + 63 = 65, but buffer is only 4 bytes + // Should fallback — rawHex is 8 chars (< 16), so returns rawHex + if hash != hex { + t.Errorf("hash=%s, want %s", hash, hex) + } +} + +func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) { + // Same as above but with rawHex >= 16 chars → returns first 16 + hex := "053F" + strings.Repeat("AA", 20) // 44 chars total, but pathByte claims 63 hops + hash := ComputeContentHash(hex) + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } + if hash != hex[:16] { + t.Errorf("hash=%s, want %s", hash, hex[:16]) + } +} + +func TestComputeContentHashTransportBeyondBuffer(t *testing.T) { + // Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes + // total buffer too short for transport codes + path + // header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes + // payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes + hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes + hash := ComputeContentHash(hex) + // payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashLongFallback(t *testing.T) { + // Long rawHex (>= 16) but invalid → returns first 16 chars + longInvalid := "ZZZZZZZZZZZZZZZZZZZZZZZZ" + hash := ComputeContentHash(longInvalid) + if hash != longInvalid[:16] { + t.Errorf("hash=%s, want first 16 of input", hash) + } +} + +func TestDecodePacketWithWhitespace(t *testing.T) { + raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76" + pkt, err := DecodePacket(raw) + if err != nil { + t.Fatal(err) + } + if pkt.Header.PayloadType != PayloadTXT_MSG { + t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG) + } +} + +func TestDecodePacketWithNewlines(t *testing.T) { + raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976" + pkt, err := DecodePacket(raw) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.Type != "TXT_MSG" { + t.Errorf("type=%s, want TXT_MSG", pkt.Payload.Type) + } +} + +func TestDecodePacketTransportRouteTooShort(t *testing.T) { + // TRANSPORT_FLOOD (route=0) but only 3 bytes total → too short for transport codes + _, err := DecodePacket("140011") + if err == nil { + t.Error("expected error for transport route with too-short buffer") + } + if !strings.Contains(err.Error(), "transport codes") { + t.Errorf("error should mention transport codes: %v", err) + } +} + +func TestDecodeAckShort(t *testing.T) { + p := decodeAck([]byte{0x01, 0x02, 0x03}) + if p.Error != "too short" { + t.Errorf("expected 'too short', got %q", p.Error) + } +} + +func TestDecodeAckValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + p := decodeAck(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "aa" { + t.Errorf("destHash=%s, want aa", p.DestHash) + } + if p.ExtraHash != "ccddeeff" { + t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash) + } +} + +func TestIsTransportRoute(t *testing.T) { + if !isTransportRoute(RouteTransportFlood) { + t.Error("RouteTransportFlood should be transport") + } + if !isTransportRoute(RouteTransportDirect) { + t.Error("RouteTransportDirect should be transport") + } + if isTransportRoute(RouteFlood) { + t.Error("RouteFlood should not be transport") + } + if isTransportRoute(RouteDirect) { + t.Error("RouteDirect should not be transport") + } +} + +func TestDecodeHeaderUnknownTypes(t *testing.T) { + // Payload type that doesn't map to any known name + // bits 5-2 = 0x0C (12) is CONTROL but 0x0D (13) would be unknown + // byte = 0b00_1101_01 = 0x35 → routeType=1, payloadType=0x0D(13), version=0 + h := decodeHeader(0x35) + if h.PayloadTypeName != "UNKNOWN" { + t.Errorf("payloadTypeName=%s, want UNKNOWN for type 13", h.PayloadTypeName) + } +} + +func TestDecodePayloadMultipart(t *testing.T) { + // MULTIPART (0x0A) falls through to default → UNKNOWN + p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}) + if p.Type != "UNKNOWN" { + t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadControl(t *testing.T) { + // CONTROL (0x0B) falls through to default → UNKNOWN + p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}) + if p.Type != "UNKNOWN" { + t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePathTruncatedBuffer(t *testing.T) { + // path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available + path, consumed := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0) + if path.HashCount != 5 { + t.Errorf("hashCount=%d, want 5", path.HashCount) + } + // Should only decode 2 hops (4 bytes / 2 bytes per hop) + if len(path.Hops) != 2 { + t.Errorf("hops=%d, want 2 (truncated)", len(path.Hops)) + } + if consumed != 10 { + t.Errorf("consumed=%d, want 10 (full claimed size)", consumed) + } +} + func TestDecodeFloodAdvert5Hops(t *testing.T) { // From test-decoder.js Test 1 raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172" diff --git a/cmd/ingestor/main_test.go b/cmd/ingestor/main_test.go new file mode 100644 index 00000000..2aa0af30 --- /dev/null +++ b/cmd/ingestor/main_test.go @@ -0,0 +1,494 @@ +package main + +import ( + "encoding/json" + "math" + "testing" + "time" +) + +func TestToFloat64(t *testing.T) { + tests := []struct { + name string + input interface{} + want float64 + wantOK bool + }{ + {"float64", float64(3.14), 3.14, true}, + {"float32", float32(2.5), 2.5, true}, + {"int", int(42), 42.0, true}, + {"int64", int64(100), 100.0, true}, + {"json.Number valid", json.Number("9.5"), 9.5, true}, + {"json.Number invalid", json.Number("not_a_number"), 0, false}, + {"string unsupported", "hello", 0, false}, + {"bool unsupported", true, 0, false}, + {"nil unsupported", nil, 0, false}, + {"slice unsupported", []int{1}, 0, false}, + {"float64 zero", float64(0), 0.0, true}, + {"float64 negative", float64(-5.5), -5.5, true}, + {"int64 large", int64(math.MaxInt32), float64(math.MaxInt32), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := toFloat64(tt.input) + if ok != tt.wantOK { + t.Errorf("toFloat64(%v) ok=%v, want %v", tt.input, ok, tt.wantOK) + } + if ok && got != tt.want { + t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestFirstNonEmpty(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"all empty", []string{"", "", ""}, ""}, + {"first non-empty", []string{"", "hello", "world"}, "hello"}, + {"first value", []string{"first", "second"}, "first"}, + {"single empty", []string{""}, ""}, + {"single value", []string{"only"}, "only"}, + {"no args", nil, ""}, + {"empty then value", []string{"", "", "last"}, "last"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := firstNonEmpty(tt.args...) + if got != tt.want { + t.Errorf("firstNonEmpty(%v) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} + +func TestUnixTime(t *testing.T) { + tests := []struct { + name string + epoch int64 + want time.Time + }{ + {"zero epoch", 0, time.Unix(0, 0)}, + {"known date", 1700000000, time.Unix(1700000000, 0)}, + {"negative epoch", -1, time.Unix(-1, 0)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unixTime(tt.epoch) + if !got.Equal(tt.want) { + t.Errorf("unixTime(%d) = %v, want %v", tt.epoch, got, tt.want) + } + }) + } +} + +// mockMessage implements mqtt.Message for testing handleMessage +type mockMessage struct { + topic string + payload []byte +} + +func (m *mockMessage) Duplicate() bool { return false } +func (m *mockMessage) Qos() byte { return 0 } +func (m *mockMessage) Retained() bool { return false } +func (m *mockMessage) Topic() string { return m.topic } +func (m *mockMessage) MessageID() uint16 { return 0 } +func (m *mockMessage) Payload() []byte { return m.payload } +func (m *mockMessage) Ack() {} + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + dbPath := dir + "/test.db" + s, err := OpenStore(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestHandleMessageRawPacket(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`) + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload} + + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Errorf("transmissions count=%d, want 1", count) + } +} + +func TestHandleMessageRawPacketAdvert(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" + payload := []byte(`{"raw":"` + rawHex + `"}`) + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload} + + handleMessage(store, "test", source, msg) + + // Should create a node from the ADVERT + var count int + store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count) + if count != 1 { + t.Errorf("nodes count=%d, want 1 (advert should upsert node)", count) + } + + // Should create observer + store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count) + if count != 1 { + t.Errorf("observers count=%d, want 1", count) + } +} + +func TestHandleMessageInvalidJSON(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)} + + // Should not panic + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Error("invalid JSON should not insert") + } +} + +func TestHandleMessageStatusTopic(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/status", + payload: []byte(`{"origin":"MyObserver"}`), + } + + handleMessage(store, "test", source, msg) + + var name, iata string + err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata) + if err != nil { + t.Fatal(err) + } + if name != "MyObserver" { + t.Errorf("name=%s, want MyObserver", name) + } + if iata != "SJC" { + t.Errorf("iata=%s, want SJC", iata) + } +} + +func TestHandleMessageSkipStatusTopics(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + // meshcore/status should be skipped + msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)} + handleMessage(store, "test", source, msg1) + + // meshcore/events/connection should be skipped + msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)} + handleMessage(store, "test", source, msg2) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Error("status/connection topics should be skipped") + } +} + +func TestHandleMessageIATAFilter(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test", IATAFilter: []string{"LAX"}} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + // SJC is not in filter, should be skipped + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Error("IATA filter should skip non-matching regions") + } + + // LAX is in filter, should be accepted + msg2 := &mockMessage{ + topic: "meshcore/LAX/obs2/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg2) + + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Errorf("IATA filter should allow matching region, got count=%d", count) + } +} + +func TestHandleMessageIATAFilterNoRegion(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test", IATAFilter: []string{"LAX"}} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + // topic with only 1 part — no region to filter on + msg := &mockMessage{ + topic: "meshcore", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg) + + // No region part → filter doesn't apply, message goes through + // Actually the code checks len(parts) > 1 for IATA filter + // Without > 1 parts, the filter is skipped and the message proceeds +} + +func TestHandleMessageNoRawHex(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + // Valid JSON but no "raw" field → falls through to "other formats" + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"type":"companion","data":"something"}`), + } + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Error("no raw hex should not insert") + } +} + +func TestHandleMessageBadRawHex(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + // Invalid hex → decode error + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"ZZZZ"}`), + } + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 0 { + t.Error("bad hex should not insert") + } +} + +func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`) + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload} + + handleMessage(store, "test", source, msg) + + var snr, rssi *float64 + store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi) + if snr == nil || *snr != 7.2 { + t.Errorf("snr=%v, want 7.2", snr) + } +} + +func TestHandleMessageMinimalTopic(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + // Topic with only 2 parts: meshcore/region (no observer ID) + msg := &mockMessage{ + topic: "meshcore/SJC", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Errorf("should insert even with short topic, got count=%d", count) + } +} + +func TestHandleMessageCorruptedAdvert(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + // An ADVERT that's too short to be valid — decoded but fails ValidateAdvert + // header 0x12 = FLOOD+ADVERT, path 0x00 = 0 hops + // Then a short payload that decodeAdvert will mark as "too short for advert" + rawHex := "1200" + "AABBCCDD" + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg) + + // Transmission should be inserted (even if advert is invalid) + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Errorf("transmission should be inserted even with corrupted advert, got %d", count) + } + + // But no node should be created + store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count) + if count != 0 { + t.Error("corrupted advert should not create a node") + } +} + +func TestHandleMessageNoObserverID(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + // Topic with only 1 part — no observer + msg := &mockMessage{ + topic: "packets", + payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`), + } + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Errorf("count=%d, want 1", count) + } + // No observer should be upserted since observerID is empty + store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count) + if count != 0 { + t.Error("no observer should be created when observerID is empty") + } +} + +func TestHandleMessageSNRNotFloat(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + // SNR as a string value — should not parse as float + payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`) + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload} + handleMessage(store, "test", source, msg) + + var count int + store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count) + if count != 1 { + t.Error("should still insert even with bad SNR/RSSI") + } +} + +func TestHandleMessageOriginExtraction(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`) + msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload} + handleMessage(store, "test", source, msg) + + // Verify origin was extracted to observer name + var name string + store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name) + if name != "MyOrigin" { + t.Errorf("observer name=%s, want MyOrigin", name) + } +} + +func TestHandleMessagePanicRecovery(t *testing.T) { + // Close the store to cause panics on prepared statement use + store := newTestStore(t) + store.Close() + + source := MQTTSource{Name: "test"} + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + + // Should not panic — the defer/recover should catch it + handleMessage(store, "test", source, msg) +} + +func TestHandleMessageStatusOriginFallback(t *testing.T) { + store := newTestStore(t) + source := MQTTSource{Name: "test"} + + // Status topic without origin field + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/status", + payload: []byte(`{"type":"status"}`), + } + handleMessage(store, "test", source, msg) + + var name string + err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name) + if err != nil { + t.Fatal(err) + } + // firstNonEmpty with empty name should use observerID as fallback in log + // The observer should still be inserted +} + +func TestEpochToISO(t *testing.T) { + // epoch 0 → 1970-01-01 + iso := epochToISO(0) + if iso != "1970-01-01T00:00:00.000Z" { + t.Errorf("epochToISO(0) = %s, want 1970-01-01T00:00:00.000Z", iso) + } + + // Known timestamp + iso2 := epochToISO(1700000000) + if iso2 == "" { + t.Error("epochToISO should return non-empty string") + } +} + +func TestAdvertRole(t *testing.T) { + tests := []struct { + name string + flags *AdvertFlags + want string + }{ + {"repeater", &AdvertFlags{Repeater: true}, "repeater"}, + {"room", &AdvertFlags{Room: true}, "room"}, + {"sensor", &AdvertFlags{Sensor: true}, "sensor"}, + {"companion (default)", &AdvertFlags{Chat: true}, "companion"}, + {"companion (no flags)", &AdvertFlags{}, "companion"}, + {"repeater takes priority", &AdvertFlags{Repeater: true, Room: true}, "repeater"}, + {"room before sensor", &AdvertFlags{Room: true, Sensor: true}, "room"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := advertRole(tt.flags) + if got != tt.want { + t.Errorf("advertRole(%+v) = %s, want %s", tt.flags, got, tt.want) + } + }) + } +} diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go new file mode 100644 index 00000000..2a6f5cfc --- /dev/null +++ b/cmd/server/config_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigValidJSON(t *testing.T) { + dir := t.TempDir() + cfgData := map[string]interface{}{ + "port": 8080, + "dbPath": "/custom/path.db", + "branding": map[string]interface{}{ + "siteName": "TestSite", + }, + "mapDefaults": map[string]interface{}{ + "center": []float64{40.0, -74.0}, + "zoom": 12, + }, + "regions": map[string]string{ + "SJC": "San Jose", + }, + "healthThresholds": map[string]interface{}{ + "infraDegradedMs": 100000, + "infraSilentMs": 200000, + "nodeDegradedMs": 50000, + "nodeSilentMs": 100000, + }, + "liveMap": map[string]interface{}{ + "propagationBufferMs": 3000, + }, + } + data, _ := json.Marshal(cfgData) + os.WriteFile(filepath.Join(dir, "config.json"), data, 0644) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Port != 8080 { + t.Errorf("expected port 8080, got %d", cfg.Port) + } + if cfg.DBPath != "/custom/path.db" { + t.Errorf("expected /custom/path.db, got %s", cfg.DBPath) + } + if cfg.MapDefaults.Zoom != 12 { + t.Errorf("expected zoom 12, got %d", cfg.MapDefaults.Zoom) + } +} + +func TestLoadConfigFromDataSubdir(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, "data") + os.Mkdir(dataDir, 0755) + cfgData := map[string]interface{}{"port": 9090} + data, _ := json.Marshal(cfgData) + os.WriteFile(filepath.Join(dataDir, "config.json"), data, 0644) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Port != 9090 { + t.Errorf("expected port 9090, got %d", cfg.Port) + } +} + +func TestLoadConfigNoFiles(t *testing.T) { + dir := t.TempDir() + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Port != 3000 { + t.Errorf("expected default port 3000, got %d", cfg.Port) + } +} + +func TestLoadConfigInvalidJSON(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid"), 0644) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + // Should return defaults when JSON is invalid + if cfg.Port != 3000 { + t.Errorf("expected default port 3000, got %d", cfg.Port) + } +} + +func TestLoadConfigNoArgs(t *testing.T) { + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } +} + +func TestLoadThemeValidJSON(t *testing.T) { + dir := t.TempDir() + themeData := map[string]interface{}{ + "branding": map[string]interface{}{ + "siteName": "CustomTheme", + }, + "theme": map[string]interface{}{ + "accent": "#ff0000", + }, + "nodeColors": map[string]interface{}{ + "repeater": "#00ff00", + }, + } + data, _ := json.Marshal(themeData) + os.WriteFile(filepath.Join(dir, "theme.json"), data, 0644) + + theme := LoadTheme(dir) + if theme.Branding == nil { + t.Fatal("expected branding") + } + if theme.Branding["siteName"] != "CustomTheme" { + t.Errorf("expected CustomTheme, got %v", theme.Branding["siteName"]) + } + if theme.Theme["accent"] != "#ff0000" { + t.Errorf("expected #ff0000, got %v", theme.Theme["accent"]) + } +} + +func TestLoadThemeFromDataSubdir(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, "data") + os.Mkdir(dataDir, 0755) + themeData := map[string]interface{}{ + "branding": map[string]interface{}{"siteName": "DataTheme"}, + } + data, _ := json.Marshal(themeData) + os.WriteFile(filepath.Join(dataDir, "theme.json"), data, 0644) + + theme := LoadTheme(dir) + if theme.Branding == nil { + t.Fatal("expected branding") + } + if theme.Branding["siteName"] != "DataTheme" { + t.Errorf("expected DataTheme, got %v", theme.Branding["siteName"]) + } +} + +func TestLoadThemeNoFile(t *testing.T) { + dir := t.TempDir() + theme := LoadTheme(dir) + if theme == nil { + t.Fatal("expected non-nil theme") + } +} + +func TestLoadThemeNoArgs(t *testing.T) { + theme := LoadTheme() + if theme == nil { + t.Fatal("expected non-nil theme") + } +} + +func TestLoadThemeInvalidJSON(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "theme.json"), []byte("{bad json"), 0644) + theme := LoadTheme(dir) + // Should return empty theme + if theme == nil { + t.Fatal("expected non-nil theme") + } +} + +func TestGetHealthThresholdsDefaults(t *testing.T) { + cfg := &Config{} + ht := cfg.GetHealthThresholds() + + if ht.InfraDegradedMs != 86400000 { + t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs) + } + if ht.InfraSilentMs != 259200000 { + t.Errorf("expected 259200000, got %d", ht.InfraSilentMs) + } + if ht.NodeDegradedMs != 3600000 { + t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs) + } + if ht.NodeSilentMs != 86400000 { + t.Errorf("expected 86400000, got %d", ht.NodeSilentMs) + } +} + +func TestGetHealthThresholdsCustom(t *testing.T) { + cfg := &Config{ + HealthThresholds: &HealthThresholds{ + InfraDegradedMs: 100000, + InfraSilentMs: 200000, + NodeDegradedMs: 50000, + NodeSilentMs: 100000, + }, + } + ht := cfg.GetHealthThresholds() + + if ht.InfraDegradedMs != 100000 { + t.Errorf("expected 100000, got %d", ht.InfraDegradedMs) + } + if ht.InfraSilentMs != 200000 { + t.Errorf("expected 200000, got %d", ht.InfraSilentMs) + } + if ht.NodeDegradedMs != 50000 { + t.Errorf("expected 50000, got %d", ht.NodeDegradedMs) + } + if ht.NodeSilentMs != 100000 { + t.Errorf("expected 100000, got %d", ht.NodeSilentMs) + } +} + +func TestGetHealthThresholdsPartialCustom(t *testing.T) { + cfg := &Config{ + HealthThresholds: &HealthThresholds{ + InfraDegradedMs: 100000, + // Others left as zero → should use defaults + }, + } + ht := cfg.GetHealthThresholds() + + if ht.InfraDegradedMs != 100000 { + t.Errorf("expected 100000, got %d", ht.InfraDegradedMs) + } + if ht.InfraSilentMs != 259200000 { + t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs) + } +} + +func TestGetHealthMs(t *testing.T) { + ht := HealthThresholds{ + InfraDegradedMs: 86400000, + InfraSilentMs: 259200000, + NodeDegradedMs: 3600000, + NodeSilentMs: 86400000, + } + + tests := []struct { + role string + wantDeg int + wantSilent int + }{ + {"repeater", 86400000, 259200000}, + {"room", 86400000, 259200000}, + {"companion", 3600000, 86400000}, + {"sensor", 3600000, 86400000}, + {"unknown", 3600000, 86400000}, + } + + for _, tc := range tests { + t.Run(tc.role, func(t *testing.T) { + deg, sil := ht.GetHealthMs(tc.role) + if deg != tc.wantDeg { + t.Errorf("degraded: expected %d, got %d", tc.wantDeg, deg) + } + if sil != tc.wantSilent { + t.Errorf("silent: expected %d, got %d", tc.wantSilent, sil) + } + }) + } +} + +func TestResolveDBPath(t *testing.T) { + t.Run("DBPath set", func(t *testing.T) { + cfg := &Config{DBPath: "/explicit/path.db"} + got := cfg.ResolveDBPath("/base") + if got != "/explicit/path.db" { + t.Errorf("expected /explicit/path.db, got %s", got) + } + }) + + t.Run("env var", func(t *testing.T) { + cfg := &Config{} + t.Setenv("DB_PATH", "/env/path.db") + got := cfg.ResolveDBPath("/base") + if got != "/env/path.db" { + t.Errorf("expected /env/path.db, got %s", got) + } + }) + + t.Run("default", func(t *testing.T) { + cfg := &Config{} + t.Setenv("DB_PATH", "") + got := cfg.ResolveDBPath("/base") + expected := filepath.Join("/base", "data", "meshcore.db") + if got != expected { + t.Errorf("expected %s, got %s", expected, got) + } + }) +} + +func TestPropagationBufferMs(t *testing.T) { + t.Run("default", func(t *testing.T) { + cfg := &Config{} + if cfg.PropagationBufferMs() != 5000 { + t.Errorf("expected 5000, got %d", cfg.PropagationBufferMs()) + } + }) + + t.Run("custom", func(t *testing.T) { + cfg := &Config{} + cfg.LiveMap.PropagationBufferMs = 3000 + if cfg.PropagationBufferMs() != 3000 { + t.Errorf("expected 3000, got %d", cfg.PropagationBufferMs()) + } + }) +} diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go new file mode 100644 index 00000000..f9b3b140 --- /dev/null +++ b/cmd/server/db_test.go @@ -0,0 +1,1265 @@ +package main + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// setupTestDB creates an in-memory SQLite database with the v3 schema. +func setupTestDB(t *testing.T) *DB { + t.Helper() + conn, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + + // Create schema matching MeshCore Analyzer v3 + schema := ` + CREATE TABLE nodes ( + public_key TEXT PRIMARY KEY, + name TEXT, + role TEXT, + lat REAL, + lon REAL, + last_seen TEXT, + first_seen TEXT, + advert_count INTEGER DEFAULT 0 + ); + + CREATE TABLE observers ( + id TEXT PRIMARY KEY, + name TEXT, + iata TEXT, + last_seen TEXT, + first_seen TEXT, + packet_count INTEGER DEFAULT 0, + model TEXT, + firmware TEXT, + client_version TEXT, + radio TEXT, + battery_mv INTEGER, + uptime_secs INTEGER, + noise_floor INTEGER + ); + + CREATE TABLE transmissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + raw_hex TEXT NOT NULL, + hash TEXT NOT NULL UNIQUE, + first_seen TEXT NOT NULL, + route_type INTEGER, + payload_type INTEGER, + payload_version INTEGER, + decoded_json TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transmission_id INTEGER NOT NULL REFERENCES transmissions(id), + observer_idx INTEGER, + direction TEXT, + snr REAL, + rssi REAL, + score INTEGER, + path_json TEXT, + timestamp INTEGER NOT NULL + ); + + CREATE VIEW packets_v AS + SELECT o.id, t.raw_hex, + datetime(o.timestamp, 'unixepoch') AS timestamp, + obs.id AS observer_id, obs.name AS observer_name, + o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type, + t.payload_type, t.payload_version, o.path_json, t.decoded_json, + t.created_at + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx; + ` + if _, err := conn.Exec(schema); err != nil { + t.Fatal(err) + } + + return &DB{conn: conn} +} + +func seedTestData(t *testing.T, db *DB) { + t.Helper() + // Seed observers + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs1', 'Observer One', 'SJC', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 100)`) + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs2', 'Observer Two', 'SFO', '2026-01-15T09:00:00Z', '2026-01-01T00:00:00Z', 50)`) + + // Seed nodes + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES ('aabbccdd11223344', 'TestRepeater', 'repeater', 37.5, -122.0, '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 50)`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES ('eeff00112233aabb', 'TestCompanion', 'companion', 37.6, -122.1, '2026-01-15T09:00:00Z', '2026-01-01T00:00:00Z', 10)`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES ('1122334455667788', 'TestRoom', 'room', 37.4, -121.9, '2026-01-14T10:00:00Z', '2026-01-01T00:00:00Z', 5)`) + + // Seed transmissions + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('AABB', 'abc123def4567890', '2026-01-15T10:00:00Z', 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('CCDD', '1234567890abcdef', '2026-01-15T09:30:00Z', 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}')`) + + // Seed observations (use unix timestamps) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 12.5, -90, '["aa","bb"]', 1736935200)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 2, 8.0, -95, '["aa"]', 1736935100)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 15.0, -85, '[]', 1736933400)`) +} + +func TestGetStats(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + stats, err := db.GetStats() + if err != nil { + t.Fatal(err) + } + + if stats.TotalTransmissions != 2 { + t.Errorf("expected 2 transmissions, got %d", stats.TotalTransmissions) + } + if stats.TotalNodes != 3 { + t.Errorf("expected 3 nodes, got %d", stats.TotalNodes) + } + if stats.TotalObservers != 2 { + t.Errorf("expected 2 observers, got %d", stats.TotalObservers) + } + if stats.TotalObservations != 3 { + t.Errorf("expected 3 observations, got %d", stats.TotalObservations) + } +} + +func TestGetRoleCounts(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + counts := db.GetRoleCounts() + if counts["repeaters"] != 1 { + t.Errorf("expected 1 repeater, got %d", counts["repeaters"]) + } + if counts["companions"] != 1 { + t.Errorf("expected 1 companion, got %d", counts["companions"]) + } + if counts["rooms"] != 1 { + t.Errorf("expected 1 room, got %d", counts["rooms"]) + } +} + +func TestQueryPackets(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total != 3 { + t.Errorf("expected 3 total packets, got %d", result.Total) + } + if len(result.Packets) != 3 { + t.Errorf("expected 3 packets, got %d", len(result.Packets)) + } +} + +func TestQueryPacketsWithTypeFilter(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + pt := 4 + result, err := db.QueryPackets(PacketQuery{Limit: 50, Type: &pt, Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total != 2 { + t.Errorf("expected 2 ADVERT packets, got %d", result.Total) + } +} + +func TestQueryGroupedPackets(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50}) + if err != nil { + t.Fatal(err) + } + if result.Total != 2 { + t.Errorf("expected 2 grouped packets (unique hashes), got %d", result.Total) + } +} + +func TestGetNodeByPubkey(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + node, err := db.GetNodeByPubkey("aabbccdd11223344") + if err != nil { + t.Fatal(err) + } + if node == nil { + t.Fatal("expected node, got nil") + } + if node["name"] != "TestRepeater" { + t.Errorf("expected TestRepeater, got %v", node["name"]) + } +} + +func TestGetNodeByPubkeyNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + node, _ := db.GetNodeByPubkey("nonexistent") + if node != nil { + t.Error("expected nil for nonexistent node") + } +} + +func TestSearchNodes(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + nodes, err := db.SearchNodes("Test", 10) + if err != nil { + t.Fatal(err) + } + if len(nodes) != 3 { + t.Errorf("expected 3 nodes matching 'Test', got %d", len(nodes)) + } +} + +func TestGetObservers(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + observers, err := db.GetObservers() + if err != nil { + t.Fatal(err) + } + if len(observers) != 2 { + t.Errorf("expected 2 observers, got %d", len(observers)) + } + if observers[0].ID != "obs1" { + t.Errorf("expected obs1 first (most recent), got %s", observers[0].ID) + } +} + +func TestGetObserverByID(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + obs, err := db.GetObserverByID("obs1") + if err != nil { + t.Fatal(err) + } + if obs.ID != "obs1" { + t.Errorf("expected obs1, got %s", obs.ID) + } +} + +func TestGetObserverByIDNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + _, err := db.GetObserverByID("nonexistent") + if err == nil { + t.Error("expected error for nonexistent observer") + } +} + +func TestGetDistinctIATAs(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + codes, err := db.GetDistinctIATAs() + if err != nil { + t.Fatal(err) + } + if len(codes) != 2 { + t.Errorf("expected 2 IATA codes, got %d", len(codes)) + } +} + +func TestGetPacketByHash(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + pkt, err := db.GetPacketByHash("abc123def4567890") + if err != nil { + t.Fatal(err) + } + if pkt == nil { + t.Fatal("expected packet, got nil") + } + if pkt["hash"] != "abc123def4567890" { + t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"]) + } +} + +func TestGetTraces(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + traces, err := db.GetTraces("abc123def4567890") + if err != nil { + t.Fatal(err) + } + if len(traces) != 2 { + t.Errorf("expected 2 traces, got %d", len(traces)) + } +} + +func TestGetChannels(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + channels, err := db.GetChannels() + if err != nil { + t.Fatal(err) + } + if len(channels) != 1 { + t.Errorf("expected 1 channel, got %d", len(channels)) + } + if channels[0]["name"] != "#test" { + t.Errorf("expected #test channel, got %v", channels[0]["name"]) + } +} + +func TestGetNetworkStatus(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + ht := HealthThresholds{ + InfraDegradedMs: 86400000, + InfraSilentMs: 259200000, + NodeDegradedMs: 3600000, + NodeSilentMs: 86400000, + } + result, err := db.GetNetworkStatus(ht) + if err != nil { + t.Fatal(err) + } + total, _ := result["total"].(int) + if total != 3 { + t.Errorf("expected 3 total nodes, got %d", total) + } +} + +func TestGetMaxTransmissionID(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + maxID := db.GetMaxTransmissionID() + if maxID != 2 { + t.Errorf("expected max ID 2, got %d", maxID) + } +} + +func TestGetNewTransmissionsSince(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + txs, err := db.GetNewTransmissionsSince(0, 10) + if err != nil { + t.Fatal(err) + } + if len(txs) != 2 { + t.Errorf("expected 2 new transmissions, got %d", len(txs)) + } + + txs, err = db.GetNewTransmissionsSince(1, 10) + if err != nil { + t.Fatal(err) + } + if len(txs) != 1 { + t.Errorf("expected 1 new transmission after ID 1, got %d", len(txs)) + } +} + +func TestGetObservationsForHash(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + obs, err := db.GetObservationsForHash("abc123def4567890") + if err != nil { + t.Fatal(err) + } + if len(obs) != 2 { + t.Errorf("expected 2 observations, got %d", len(obs)) + } +} + +func TestGetPacketByIDFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + pkt, err := db.GetPacketByID(1) + if err != nil { + t.Fatal(err) + } + if pkt == nil { + t.Fatal("expected packet, got nil") + } + if pkt["hash"] != "abc123def4567890" { + t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"]) + } +} + +func TestGetPacketByIDNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + pkt, err := db.GetPacketByID(9999) + if err != nil { + t.Fatal(err) + } + if pkt != nil { + t.Error("expected nil for nonexistent packet ID") + } +} + +func TestGetTransmissionByIDFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + tx, err := db.GetTransmissionByID(1) + if err != nil { + t.Fatal(err) + } + if tx == nil { + t.Fatal("expected transmission, got nil") + } + if tx["hash"] != "abc123def4567890" { + t.Errorf("expected hash abc123def4567890, got %v", tx["hash"]) + } + if tx["raw_hex"] != "AABB" { + t.Errorf("expected raw_hex AABB, got %v", tx["raw_hex"]) + } +} + +func TestGetTransmissionByIDNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + _, err := db.GetTransmissionByID(9999) + if err == nil { + t.Error("expected error for nonexistent transmission") + } +} + +func TestGetPacketByHashNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + _, err := db.GetPacketByHash("nonexistenthash1") + if err == nil { + t.Error("expected error for nonexistent hash") + } +} + +func TestGetRecentPacketsForNode(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 20) + if err != nil { + t.Fatal(err) + } + if len(packets) == 0 { + t.Error("expected packets for TestRepeater") + } +} + +func TestGetRecentPacketsForNodeDefaultLimit(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 0) + if err != nil { + t.Fatal(err) + } + if packets == nil { + t.Error("expected non-nil result") + } +} + +func TestGetObserverIdsForRegion(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("with data", func(t *testing.T) { + ids, err := db.GetObserverIdsForRegion("SJC") + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 { + t.Errorf("expected 1 observer for SJC, got %d", len(ids)) + } + if ids[0] != "obs1" { + t.Errorf("expected obs1, got %s", ids[0]) + } + }) + + t.Run("multiple codes", func(t *testing.T) { + ids, err := db.GetObserverIdsForRegion("SJC,SFO") + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 { + t.Errorf("expected 2 observers, got %d", len(ids)) + } + }) + + t.Run("empty param", func(t *testing.T) { + ids, err := db.GetObserverIdsForRegion("") + if err != nil { + t.Fatal(err) + } + if ids != nil { + t.Error("expected nil for empty region") + } + }) + + t.Run("not found", func(t *testing.T) { + ids, err := db.GetObserverIdsForRegion("ZZZ") + if err != nil { + t.Fatal(err) + } + if len(ids) != 0 { + t.Errorf("expected 0 observers for ZZZ, got %d", len(ids)) + } + }) +} + +func TestGetNodeHealth(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("found", func(t *testing.T) { + result, err := db.GetNodeHealth("aabbccdd11223344") + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + node, ok := result["node"].(map[string]interface{}) + if !ok { + t.Fatal("expected node object") + } + if node["name"] != "TestRepeater" { + t.Errorf("expected TestRepeater, got %v", node["name"]) + } + stats, ok := result["stats"].(map[string]interface{}) + if !ok { + t.Fatal("expected stats object") + } + if stats["totalPackets"] == nil { + t.Error("expected totalPackets in stats") + } + }) + + t.Run("not found", func(t *testing.T) { + result, err := db.GetNodeHealth("nonexistent") + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Error("expected nil for nonexistent node") + } + }) +} + +func TestGetChannelMessages(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("matching channel", func(t *testing.T) { + messages, total, err := db.GetChannelMessages("#test", 100, 0) + if err != nil { + t.Fatal(err) + } + if total == 0 { + t.Error("expected at least 1 message for #test") + } + if len(messages) == 0 { + t.Error("expected non-empty messages") + } + }) + + t.Run("non-matching channel", func(t *testing.T) { + messages, total, err := db.GetChannelMessages("#nonexistent", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 0 { + t.Errorf("expected 0 messages, got %d", total) + } + if len(messages) != 0 { + t.Errorf("expected empty messages, got %d", len(messages)) + } + }) + + t.Run("default limit", func(t *testing.T) { + messages, _, err := db.GetChannelMessages("#test", 0, 0) + if err != nil { + t.Fatal(err) + } + if messages == nil { + t.Error("expected non-nil result") + } + }) +} + +func TestGetTimestamps(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("with results", func(t *testing.T) { + ts, err := db.GetTimestamps("2020-01-01") + if err != nil { + t.Fatal(err) + } + if len(ts) == 0 { + t.Error("expected timestamps") + } + }) + + t.Run("no results", func(t *testing.T) { + ts, err := db.GetTimestamps("2099-01-01") + if err != nil { + t.Fatal(err) + } + if len(ts) != 0 { + t.Errorf("expected 0 timestamps, got %d", len(ts)) + } + }) +} + +func TestGetObservationCount(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + count := db.GetObservationCount("abc123def4567890") + if count != 2 { + t.Errorf("expected 2, got %d", count) + } + + count = db.GetObservationCount("nonexistent") + if count != 0 { + t.Errorf("expected 0 for nonexistent, got %d", count) + } +} + +func TestBuildPacketWhereFilters(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("type filter", func(t *testing.T) { + pt := 4 + result, err := db.QueryPackets(PacketQuery{Limit: 50, Type: &pt, Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for type=4") + } + }) + + t.Run("route filter", func(t *testing.T) { + rt := 1 + result, err := db.QueryPackets(PacketQuery{Limit: 50, Route: &rt, Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for route=1") + } + }) + + t.Run("observer filter", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Observer: "obs1", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for observer=obs1") + } + }) + + t.Run("hash filter", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Hash: "abc123def4567890", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total != 2 { + t.Errorf("expected 2 results for hash filter, got %d", result.Total) + } + }) + + t.Run("since filter", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Since: "2020-01-01", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for since filter") + } + }) + + t.Run("until filter", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Until: "2099-01-01", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for until filter") + } + }) + + t.Run("region filter", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Region: "SJC", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for region=SJC") + } + }) + + t.Run("node filter by name", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Node: "TestRepeater", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for node=TestRepeater") + } + }) + + t.Run("node filter by pubkey", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{Limit: 50, Node: "aabbccdd11223344", Order: "DESC"}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for node pubkey filter") + } + }) + + t.Run("combined filters", func(t *testing.T) { + pt := 4 + rt := 1 + result, err := db.QueryPackets(PacketQuery{ + Limit: 50, + Type: &pt, + Route: &rt, + Observer: "obs1", + Since: "2020-01-01", + Order: "DESC", + }) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results with combined filters") + } + }) + + t.Run("default limit", func(t *testing.T) { + result, err := db.QueryPackets(PacketQuery{}) + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Error("expected non-nil result") + } + }) +} + +func TestResolveNodePubkey(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("by pubkey", func(t *testing.T) { + pk := db.resolveNodePubkey("aabbccdd11223344") + if pk != "aabbccdd11223344" { + t.Errorf("expected aabbccdd11223344, got %s", pk) + } + }) + + t.Run("by name", func(t *testing.T) { + pk := db.resolveNodePubkey("TestRepeater") + if pk != "aabbccdd11223344" { + t.Errorf("expected aabbccdd11223344, got %s", pk) + } + }) + + t.Run("not found returns input", func(t *testing.T) { + pk := db.resolveNodePubkey("nonexistent") + if pk != "nonexistent" { + t.Errorf("expected 'nonexistent' back, got %s", pk) + } + }) +} + +func TestGetNodesFiltering(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("role filter", func(t *testing.T) { + nodes, total, _, err := db.GetNodes(50, 0, "repeater", "", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Errorf("expected 1 repeater, got %d", total) + } + if len(nodes) != 1 { + t.Errorf("expected 1 node, got %d", len(nodes)) + } + }) + + t.Run("search filter", func(t *testing.T) { + nodes, _, _, err := db.GetNodes(50, 0, "", "Companion", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if len(nodes) != 1 { + t.Errorf("expected 1 companion, got %d", len(nodes)) + } + }) + + t.Run("sort by name", func(t *testing.T) { + nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "name", "") + if err != nil { + t.Fatal(err) + } + if len(nodes) == 0 { + t.Error("expected nodes") + } + }) + + t.Run("sort by packetCount", func(t *testing.T) { + nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "packetCount", "") + if err != nil { + t.Fatal(err) + } + if len(nodes) == 0 { + t.Error("expected nodes") + } + }) + + t.Run("sort by lastSeen", func(t *testing.T) { + nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "lastSeen", "") + if err != nil { + t.Fatal(err) + } + if len(nodes) == 0 { + t.Error("expected nodes") + } + }) + + t.Run("lastHeard filter 30d", func(t *testing.T) { + // The filter works by computing since = now - 30d; seed data last_seen may or may not match. + // Just verify the filter runs without error. + _, _, _, err := db.GetNodes(50, 0, "", "", "", "30d", "", "") + if err != nil { + t.Fatal(err) + } + }) + + t.Run("lastHeard filter various", func(t *testing.T) { + for _, lh := range []string{"1h", "6h", "24h", "7d", "30d", "invalid"} { + _, _, _, err := db.GetNodes(50, 0, "", "", "", lh, "", "") + if err != nil { + t.Fatalf("lastHeard=%s failed: %v", lh, err) + } + } + }) + + t.Run("default limit", func(t *testing.T) { + nodes, _, _, err := db.GetNodes(0, 0, "", "", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if len(nodes) == 0 { + t.Error("expected nodes with default limit") + } + }) + + t.Run("before filter", func(t *testing.T) { + _, total, _, err := db.GetNodes(50, 0, "", "", "2026-01-02T00:00:00Z", "", "", "") + if err != nil { + t.Fatal(err) + } + if total != 3 { + t.Errorf("expected 3 nodes with first_seen <= 2026-01-02, got %d", total) + } + }) + + t.Run("offset", func(t *testing.T) { + nodes, total, _, err := db.GetNodes(1, 1, "", "", "", "", "", "") + if err != nil { + t.Fatal(err) + } + if total != 3 { + t.Errorf("expected 3 total, got %d", total) + } + if len(nodes) != 1 { + t.Errorf("expected 1 node with offset, got %d", len(nodes)) + } + }) +} + +func TestGetChannelMessagesDedup(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Seed observers + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`) + + // Insert two transmissions with same hash to test dedup + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#general","text":"User1: Hello","sender":"User1"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5, + '{"type":"CHAN","channel":"#general","text":"User2: World","sender":"User2"}')`) + + // Observations: first msg seen by two observers (dedup), second by one + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 12.0, -90, '["aa"]', 1736935200)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 2, 10.0, -92, '["aa"]', 1736935210)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 14.0, -88, '[]', 1736935260)`) + + messages, total, err := db.GetChannelMessages("#general", 100, 0) + if err != nil { + t.Fatal(err) + } + // Two unique messages (deduped by sender:hash) + if total < 2 { + t.Errorf("expected at least 2 unique messages, got %d", total) + } + if len(messages) < 2 { + t.Errorf("expected at least 2 messages, got %d", len(messages)) + } + + // Verify dedup: first message should have repeats > 1 because 2 observations + found := false + for _, m := range messages { + if m["text"] == "Hello" { + found = true + repeats, _ := m["repeats"].(int) + if repeats < 2 { + t.Errorf("expected repeats >= 2 for deduped msg, got %d", repeats) + } + } + } + if !found { + // Message text might be parsed differently + t.Log("Note: message text parsing may vary") + } +} + +func TestGetChannelMessagesNoSender(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5, + '{"type":"CHAN","channel":"#noname","text":"plain text no colon"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 12.0, -90, null, 1736935300)`) + + messages, total, err := db.GetChannelMessages("#noname", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Errorf("expected 1 message, got %d", total) + } + if len(messages) != 1 { + t.Errorf("expected 1 message, got %d", len(messages)) + } +} + +func TestGetNetworkStatusDateFormats(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert nodes with different date formats + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) + VALUES ('node1111', 'NodeRFC', 'repeater', ?)`, time.Now().Format(time.RFC3339)) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) + VALUES ('node2222', 'NodeSQL', 'companion', ?)`, time.Now().Format("2006-01-02 15:04:05")) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) + VALUES ('node3333', 'NodeNull', 'room', NULL)`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) + VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`) + + ht := HealthThresholds{ + InfraDegradedMs: 86400000, + InfraSilentMs: 259200000, + NodeDegradedMs: 3600000, + NodeSilentMs: 86400000, + } + result, err := db.GetNetworkStatus(ht) + if err != nil { + t.Fatal(err) + } + total, _ := result["total"].(int) + if total != 4 { + t.Errorf("expected 4 nodes, got %d", total) + } + // Verify the function handles all date formats without error + active, _ := result["active"].(int) + degraded, _ := result["degraded"].(int) + silent, _ := result["silent"].(int) + if active+degraded+silent != 4 { + t.Errorf("expected sum of statuses = 4, got %d", active+degraded+silent) + } + roleCounts, ok := result["roleCounts"].(map[string]int) + if !ok { + t.Fatal("expected roleCounts map") + } + if roleCounts["repeater"] != 1 { + t.Errorf("expected 1 repeater, got %d", roleCounts["repeater"]) + } +} + +func TestOpenDBValid(t *testing.T) { + // Create a real SQLite database file + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + // Create DB with a table using a writable connection first + conn, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + _, err = conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`) + if err != nil { + conn.Close() + t.Fatal(err) + } + conn.Close() + + // Now test OpenDB (read-only) + database, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB failed: %v", err) + } + defer database.Close() + + // Verify it works + maxID := database.GetMaxTransmissionID() + if maxID != 0 { + t.Errorf("expected 0, got %d", maxID) + } +} + +func TestOpenDBInvalidPath(t *testing.T) { + _, err := OpenDB(filepath.Join(t.TempDir(), "nonexistent", "sub", "dir", "test.db")) + if err == nil { + t.Error("expected error for invalid path") + } +} + +func TestGetNodeHealthNoName(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert a node without a name + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO nodes (public_key, role, last_seen, first_seen, advert_count) + VALUES ('deadbeef12345678', 'repeater', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4, + '{"pubKey":"deadbeef12345678","type":"ADVERT"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 11.0, -91, '["dd"]', 1736935500)`) + + result, err := db.GetNodeHealth("deadbeef12345678") + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Fatal("expected result, got nil") + } +} + +func TestGetChannelMessagesObserverFallback(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Observer with ID but no name entry (observer_idx won't match) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('AA', 'chanmsg00000004', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#obs","text":"Sender: Test","sender":"Sender"}')`) + // Observation without observer (observer_idx = NULL) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, NULL, 12.0, -90, null, 1736935200)`) + + messages, total, err := db.GetChannelMessages("#obs", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Errorf("expected 1, got %d", total) + } + if len(messages) != 1 { + t.Errorf("expected 1 message, got %d", len(messages)) + } +} + +func TestGetChannelsMultiple(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer', 'SJC')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('AA', 'chan1hash', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#alpha","text":"Alice: Hello","sender":"Alice"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('BB', 'chan2hash', '2026-01-15T10:01:00Z', 1, 5, + '{"type":"CHAN","channel":"#beta","text":"Bob: World","sender":"Bob"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('CC', 'chan3hash', '2026-01-15T10:02:00Z', 1, 5, + '{"type":"CHAN","channel":"","text":"No channel"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DD', 'chan4hash', '2026-01-15T10:03:00Z', 1, 5, + '{"type":"OTHER"}')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('EE', 'chan5hash', '2026-01-15T10:04:00Z', 1, 5, 'not-valid-json')`) + + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 12.0, -90, null, 1736935200)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 12.0, -90, null, 1736935260)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 12.0, -90, null, 1736935320)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (4, 1, 12.0, -90, null, 1736935380)`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (5, 1, 12.0, -90, null, 1736935440)`) + + channels, err := db.GetChannels() + if err != nil { + t.Fatal(err) + } + // #alpha, #beta, and "unknown" (empty channel) + if len(channels) < 2 { + t.Errorf("expected at least 2 channels, got %d", len(channels)) + } +} + +func TestQueryGroupedPacketsWithFilters(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + rt := 1 + result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50, Route: &rt}) + if err != nil { + t.Fatal(err) + } + if result.Total == 0 { + t.Error("expected results for grouped with route filter") + } +} + +func TestGetTracesEmpty(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + traces, err := db.GetTraces("nonexistenthash1") + if err != nil { + t.Fatal(err) + } + if len(traces) != 0 { + t.Errorf("expected 0 traces, got %d", len(traces)) + } +} + +func TestNullHelpers(t *testing.T) { + // nullStr + if nullStr(sql.NullString{Valid: false}) != nil { + t.Error("expected nil for invalid NullString") + } + if nullStr(sql.NullString{Valid: true, String: "hello"}) != "hello" { + t.Error("expected 'hello' for valid NullString") + } + + // nullFloat + if nullFloat(sql.NullFloat64{Valid: false}) != nil { + t.Error("expected nil for invalid NullFloat64") + } + if nullFloat(sql.NullFloat64{Valid: true, Float64: 3.14}) != 3.14 { + t.Error("expected 3.14 for valid NullFloat64") + } + + // nullInt + if nullInt(sql.NullInt64{Valid: false}) != nil { + t.Error("expected nil for invalid NullInt64") + } + if nullInt(sql.NullInt64{Valid: true, Int64: 42}) != 42 { + t.Error("expected 42 for valid NullInt64") + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/cmd/server/helpers_test.go b/cmd/server/helpers_test.go new file mode 100644 index 00000000..97045430 --- /dev/null +++ b/cmd/server/helpers_test.go @@ -0,0 +1,347 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestWriteError(t *testing.T) { + w := httptest.NewRecorder() + writeError(w, 404, "Not found") + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + var body map[string]string + json.Unmarshal(w.Body.Bytes(), &body) + if body["error"] != "Not found" { + t.Errorf("expected 'Not found', got %s", body["error"]) + } +} + +func TestWriteErrorVariousCodes(t *testing.T) { + tests := []struct { + code int + msg string + }{ + {400, "Bad request"}, + {500, "Internal error"}, + {403, "Forbidden"}, + } + for _, tc := range tests { + w := httptest.NewRecorder() + writeError(w, tc.code, tc.msg) + if w.Code != tc.code { + t.Errorf("expected %d, got %d", tc.code, w.Code) + } + } +} + +func TestQueryInt(t *testing.T) { + tests := []struct { + name string + url string + key string + def int + expected int + }{ + {"valid", "/?limit=25", "limit", 50, 25}, + {"missing", "/?other=5", "limit", 50, 50}, + {"empty", "/?limit=", "limit", 50, 50}, + {"invalid", "/?limit=abc", "limit", 50, 50}, + {"zero", "/?limit=0", "limit", 50, 0}, + {"negative", "/?limit=-1", "limit", 50, -1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("GET", tc.url, nil) + got := queryInt(r, tc.key, tc.def) + if got != tc.expected { + t.Errorf("expected %d, got %d", tc.expected, got) + } + }) + } +} + +func TestMergeMap(t *testing.T) { + t.Run("basic merge", func(t *testing.T) { + base := map[string]interface{}{"a": 1, "b": 2} + overlay := map[string]interface{}{"b": 3, "c": 4} + result := mergeMap(base, overlay) + + if result["a"] != 1 { + t.Errorf("expected 1, got %v", result["a"]) + } + if result["b"] != 3 { + t.Errorf("expected 3 (overridden), got %v", result["b"]) + } + if result["c"] != 4 { + t.Errorf("expected 4, got %v", result["c"]) + } + }) + + t.Run("nil overlay", func(t *testing.T) { + base := map[string]interface{}{"a": 1} + result := mergeMap(base, nil) + if result["a"] != 1 { + t.Errorf("expected 1, got %v", result["a"]) + } + }) + + t.Run("multiple overlays", func(t *testing.T) { + base := map[string]interface{}{"a": 1} + o1 := map[string]interface{}{"b": 2} + o2 := map[string]interface{}{"c": 3, "a": 10} + result := mergeMap(base, o1, o2) + if result["a"] != 10 { + t.Errorf("expected 10, got %v", result["a"]) + } + if result["b"] != 2 { + t.Errorf("expected 2, got %v", result["b"]) + } + if result["c"] != 3 { + t.Errorf("expected 3, got %v", result["c"]) + } + }) + + t.Run("empty base", func(t *testing.T) { + result := mergeMap(map[string]interface{}{}, map[string]interface{}{"x": 5}) + if result["x"] != 5 { + t.Errorf("expected 5, got %v", result["x"]) + } + }) +} + +func TestSafeAvg(t *testing.T) { + tests := []struct { + total, count float64 + expected float64 + }{ + {100, 10, 10.0}, + {0, 0, 0}, + {33, 3, 11.0}, + {10, 3, 3.3}, + } + for _, tc := range tests { + got := safeAvg(tc.total, tc.count) + if got != tc.expected { + t.Errorf("safeAvg(%v, %v) = %v, want %v", tc.total, tc.count, got, tc.expected) + } + } +} + +func TestRound(t *testing.T) { + tests := []struct { + val float64 + places int + want float64 + }{ + {3.456, 1, 3.5}, + {3.444, 1, 3.4}, + {3.456, 2, 3.46}, + {0, 1, 0}, + {100.0, 0, 100.0}, + } + for _, tc := range tests { + got := round(tc.val, tc.places) + if got != tc.want { + t.Errorf("round(%v, %d) = %v, want %v", tc.val, tc.places, got, tc.want) + } + } +} + +func TestPercentile(t *testing.T) { + t.Run("empty", func(t *testing.T) { + if percentile([]float64{}, 0.5) != 0 { + t.Error("expected 0 for empty slice") + } + }) + + t.Run("single element", func(t *testing.T) { + if percentile([]float64{42}, 0.5) != 42 { + t.Error("expected 42") + } + }) + + t.Run("p50", func(t *testing.T) { + sorted := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + got := percentile(sorted, 0.5) + if got != 6 { + t.Errorf("expected 6 for p50, got %v", got) + } + }) + + t.Run("p95", func(t *testing.T) { + sorted := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + got := percentile(sorted, 0.95) + if got != 10 { + t.Errorf("expected 10 for p95, got %v", got) + } + }) + + t.Run("p100 clamps", func(t *testing.T) { + sorted := []float64{1, 2, 3} + got := percentile(sorted, 1.0) + if got != 3 { + t.Errorf("expected 3 for p100, got %v", got) + } + }) +} + +func TestSortedCopy(t *testing.T) { + original := []float64{5, 3, 1, 4, 2} + sorted := sortedCopy(original) + + // Original should be unchanged + if original[0] != 5 { + t.Error("original should not be modified") + } + + expected := []float64{1, 2, 3, 4, 5} + for i, v := range sorted { + if v != expected[i] { + t.Errorf("index %d: expected %v, got %v", i, expected[i], v) + } + } + + // Empty slice + empty := sortedCopy([]float64{}) + if len(empty) != 0 { + t.Error("expected empty slice") + } +} + +func TestLastN(t *testing.T) { + arr := []map[string]interface{}{ + {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}, + } + + t.Run("n less than length", func(t *testing.T) { + result := lastN(arr, 3) + if len(result) != 3 { + t.Errorf("expected 3, got %d", len(result)) + } + if result[0]["id"] != 3 { + t.Errorf("expected id 3, got %v", result[0]["id"]) + } + }) + + t.Run("n greater than length", func(t *testing.T) { + result := lastN(arr, 10) + if len(result) != 5 { + t.Errorf("expected 5, got %d", len(result)) + } + }) + + t.Run("n equals length", func(t *testing.T) { + result := lastN(arr, 5) + if len(result) != 5 { + t.Errorf("expected 5, got %d", len(result)) + } + }) + + t.Run("empty", func(t *testing.T) { + result := lastN([]map[string]interface{}{}, 5) + if len(result) != 0 { + t.Errorf("expected 0, got %d", len(result)) + } + }) +} + +func TestSpaHandler(t *testing.T) { + // Create a temp directory with test files + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "index.html"), []byte("SPA"), 0644) + os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('app')"), 0644) + os.WriteFile(filepath.Join(dir, "style.css"), []byte("body{}"), 0644) + + fs := http.FileServer(http.Dir(dir)) + handler := spaHandler(dir, fs) + + t.Run("existing JS file with cache control", func(t *testing.T) { + req := httptest.NewRequest("GET", "/app.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + cc := w.Header().Get("Cache-Control") + if cc != "no-cache, no-store, must-revalidate" { + t.Errorf("expected no-cache header for .js, got %s", cc) + } + }) + + t.Run("existing CSS file with cache control", func(t *testing.T) { + req := httptest.NewRequest("GET", "/style.css", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + cc := w.Header().Get("Cache-Control") + if cc != "no-cache, no-store, must-revalidate" { + t.Errorf("expected no-cache header for .css, got %s", cc) + } + }) + + t.Run("non-existent file falls back to index.html", func(t *testing.T) { + req := httptest.NewRequest("GET", "/some/spa/route", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != "SPA" { + t.Errorf("expected SPA index.html content, got %s", body) + } + }) + + t.Run("existing HTML file", func(t *testing.T) { + // Subdirectory with HTML file to avoid redirect from root /index.html + subDir := filepath.Join(dir, "sub") + os.Mkdir(subDir, 0755) + os.WriteFile(filepath.Join(subDir, "page.html"), []byte("page"), 0644) + + req := httptest.NewRequest("GET", "/sub/page.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + cc := w.Header().Get("Cache-Control") + if cc != "no-cache, no-store, must-revalidate" { + t.Errorf("expected no-cache header for .html, got %s", cc) + } + }) +} + +func TestWriteJSON(t *testing.T) { + w := httptest.NewRecorder() + writeJSON(w, map[string]interface{}{"key": "value"}) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["key"] != "value" { + t.Errorf("expected 'value', got %v", body["key"]) + } +} diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go new file mode 100644 index 00000000..00a4c32b --- /dev/null +++ b/cmd/server/routes_test.go @@ -0,0 +1,1623 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func setupTestServer(t *testing.T) (*Server, *mux.Router) { + t.Helper() + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + return srv, router +} + +func TestHealthEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["status"] != "ok" { + t.Errorf("expected status ok, got %v", body["status"]) + } +} + +func TestStatsEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/stats", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["totalTransmissions"] != float64(2) { + t.Errorf("expected 2 transmissions, got %v", body["totalTransmissions"]) + } + if body["totalNodes"] != float64(3) { + t.Errorf("expected 3 nodes, got %v", body["totalNodes"]) + } +} + +func TestPacketsEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + packets, ok := body["packets"].([]interface{}) + if !ok { + t.Fatal("expected packets array") + } + if len(packets) != 3 { + t.Errorf("expected 3 packets, got %d", len(packets)) + } +} + +func TestPacketsGrouped(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + packets, ok := body["packets"].([]interface{}) + if !ok { + t.Fatal("expected packets array") + } + if len(packets) != 2 { + t.Errorf("expected 2 grouped packets, got %d", len(packets)) + } +} + +func TestNodesEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes?limit=50", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + nodes, ok := body["nodes"].([]interface{}) + if !ok { + t.Fatal("expected nodes array") + } + if len(nodes) != 3 { + t.Errorf("expected 3 nodes, got %d", len(nodes)) + } + total := body["total"].(float64) + if total != 3 { + t.Errorf("expected total 3, got %v", total) + } +} + +func TestNodeDetailEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + node, ok := body["node"].(map[string]interface{}) + if !ok { + t.Fatal("expected node object") + } + if node["name"] != "TestRepeater" { + t.Errorf("expected TestRepeater, got %v", node["name"]) + } +} + +func TestNodeDetail404(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/nonexistent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestNodeSearchEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/search?q=Repeater", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + nodes, ok := body["nodes"].([]interface{}) + if !ok { + t.Fatal("expected nodes array") + } + if len(nodes) != 1 { + t.Errorf("expected 1 node matching 'Repeater', got %d", len(nodes)) + } +} + +func TestNetworkStatusEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/network-status", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["total"] != float64(3) { + t.Errorf("expected 3 total, got %v", body["total"]) + } +} + +func TestObserversEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/observers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + observers, ok := body["observers"].([]interface{}) + if !ok { + t.Fatal("expected observers array") + } + if len(observers) != 2 { + t.Errorf("expected 2 observers, got %d", len(observers)) + } +} + +func TestObserverDetail404(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/observers/nonexistent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestChannelsEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + channels, ok := body["channels"].([]interface{}) + if !ok { + t.Fatal("expected channels array") + } + if len(channels) != 1 { + t.Errorf("expected 1 channel, got %d", len(channels)) + } +} + +func TestTracesEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/traces/abc123def4567890", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + traces, ok := body["traces"].([]interface{}) + if !ok { + t.Fatal("expected traces array") + } + if len(traces) != 2 { + t.Errorf("expected 2 traces, got %d", len(traces)) + } +} + +func TestConfigCacheEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/config/cache", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestConfigThemeEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/config/theme", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["branding"] == nil { + t.Error("expected branding in theme response") + } +} + +func TestConfigMapEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/config/map", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["zoom"] == nil { + t.Error("expected zoom in map response") + } +} + +func TestPerfEndpoint(t *testing.T) { + _, router := setupTestServer(t) + // Make a request first to generate perf data + req1 := httptest.NewRequest("GET", "/api/health", nil) + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, req1) + + req := httptest.NewRequest("GET", "/api/perf", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestAnalyticsRFEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/rf", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestResolveHopsEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/resolve-hops?hops=aabb,eeff", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + resolved, ok := body["resolved"].(map[string]interface{}) + if !ok { + t.Fatal("expected resolved map") + } + // aabb should resolve to TestRepeater + aabb, ok := resolved["aabb"].(map[string]interface{}) + if !ok { + t.Fatal("expected aabb in resolved") + } + if aabb["name"] != "TestRepeater" { + t.Errorf("expected TestRepeater for aabb, got %v", aabb["name"]) + } +} + +func TestPacketTimestampsRequiresSince(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/timestamps", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestContentTypeJSON(t *testing.T) { + _, router := setupTestServer(t) + endpoints := []string{ + "/api/health", "/api/stats", "/api/nodes", "/api/packets", + "/api/observers", "/api/channels", + } + for _, ep := range endpoints { + req := httptest.NewRequest("GET", ep, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("%s: expected application/json, got %s", ep, ct) + } + } +} + +func TestAllEndpointsReturn200(t *testing.T) { + _, router := setupTestServer(t) + endpoints := []struct { + path string + status int + }{ + {"/api/health", http.StatusOK}, + {"/api/stats", http.StatusOK}, + {"/api/perf", http.StatusOK}, + {"/api/config/cache", http.StatusOK}, + {"/api/config/client", http.StatusOK}, + {"/api/config/regions", http.StatusOK}, + {"/api/config/theme", http.StatusOK}, + {"/api/config/map", http.StatusOK}, + {"/api/packets?limit=5", http.StatusOK}, + {"/api/nodes?limit=5", http.StatusOK}, + {"/api/nodes/search?q=test", http.StatusOK}, + {"/api/nodes/bulk-health", http.StatusOK}, + {"/api/nodes/network-status", http.StatusOK}, + {"/api/observers", http.StatusOK}, + {"/api/channels", http.StatusOK}, + {"/api/analytics/rf", http.StatusOK}, + {"/api/analytics/topology", http.StatusOK}, + {"/api/analytics/channels", http.StatusOK}, + {"/api/analytics/distance", http.StatusOK}, + {"/api/analytics/hash-sizes", http.StatusOK}, + {"/api/analytics/subpaths", http.StatusOK}, + {"/api/analytics/subpath-detail?hops=aa,bb", http.StatusOK}, + {"/api/resolve-hops?hops=aabb", http.StatusOK}, + {"/api/iata-coords", http.StatusOK}, + {"/api/traces/abc123def4567890", http.StatusOK}, + } + for _, tc := range endpoints { + req := httptest.NewRequest("GET", tc.path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != tc.status { + t.Errorf("%s: expected %d, got %d (body: %s)", tc.path, tc.status, w.Code, w.Body.String()[:min(200, w.Body.Len())]) + } + } +} + +func TestPacketDetailByHash(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/abc123def4567890", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + pkt, ok := body["packet"].(map[string]interface{}) + if !ok { + t.Fatal("expected packet object") + } + if pkt["hash"] != "abc123def4567890" { + t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"]) + } + if body["observation_count"] == nil { + t.Error("expected observation_count") + } +} + +func TestPacketDetailByNumericID(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["packet"] == nil { + t.Error("expected packet object") + } +} + +func TestPacketDetailNotFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/notahash12345678", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // "notahash12345678" is 16 hex chars, will try hash lookup first, then fail + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestPacketDetailNumericNotFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/99999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestPacketTimestampsWithSince(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets/timestamps?since=2020-01-01", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestNodeDetailWithRecentAdverts(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["recentAdverts"] == nil { + t.Error("expected recentAdverts in response") + } + node, ok := body["node"].(map[string]interface{}) + if !ok { + t.Fatal("expected node object") + } + if node["name"] != "TestRepeater" { + t.Errorf("expected TestRepeater, got %v", node["name"]) + } +} + +func TestNodeHealthFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["node"] == nil { + t.Error("expected node in response") + } + if body["stats"] == nil { + t.Error("expected stats in response") + } +} + +func TestNodeHealthNotFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/nonexistent/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestBulkHealthEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body []interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if len(body) != 3 { + t.Errorf("expected 3 nodes, got %d", len(body)) + } +} + +func TestBulkHealthLimitCap(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestNodePathsFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/paths", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["node"] == nil { + t.Error("expected node in response") + } + if body["paths"] == nil { + t.Error("expected paths in response") + } +} + +func TestNodePathsNotFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/nonexistent/paths", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestNodeAnalytics(t *testing.T) { + _, router := setupTestServer(t) + + t.Run("default days", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/analytics", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["timeRange"] == nil { + t.Error("expected timeRange") + } + if body["activityTimeline"] == nil { + t.Error("expected activityTimeline") + } + }) + + t.Run("custom days", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/analytics?days=30", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + }) + + t.Run("clamp days below 1", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/analytics?days=0", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + }) + + t.Run("clamp days above 365", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/analytics?days=999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + }) + + t.Run("not found", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/nodes/nonexistent/analytics", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("expected 404, got %d", w.Code) + } + }) +} + +func TestObserverDetailFound(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/observers/obs1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["id"] != "obs1" { + t.Errorf("expected obs1, got %v", body["id"]) + } +} + +func TestObserverAnalytics(t *testing.T) { + _, router := setupTestServer(t) + + t.Run("default", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["packetTypes"] == nil { + t.Error("expected packetTypes") + } + if body["recentPackets"] == nil { + t.Error("expected recentPackets") + } + }) + + t.Run("custom days", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/observers/obs1/analytics?days=1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + }) + + t.Run("days greater than 7", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/observers/obs1/analytics?days=30", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + }) +} + +func TestChannelMessages(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/channels/%23test/messages", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["messages"] == nil { + t.Error("expected messages") + } +} + +func TestAnalyticsRFWithRegion(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/rf?region=SJC", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["snr"] == nil { + t.Error("expected snr in response") + } + if body["payloadTypes"] == nil { + t.Error("expected payloadTypes") + } +} + +func TestAnalyticsTopology(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/topology", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["uniqueNodes"] == nil { + t.Error("expected uniqueNodes") + } +} + +func TestAnalyticsChannels(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["channels"] == nil { + t.Error("expected channels") + } + if body["activeChannels"] == nil { + t.Error("expected activeChannels") + } +} + +func TestAnalyticsDistance(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/distance", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestAnalyticsHashSizes(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestAnalyticsSubpaths(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/subpaths", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestAnalyticsSubpathDetailWithHops(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/subpath-detail?hops=aa,bb", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + hops, ok := body["hops"].([]interface{}) + if !ok { + t.Fatal("expected hops array") + } + if len(hops) != 2 { + t.Errorf("expected 2 hops, got %d", len(hops)) + } +} + +func TestAnalyticsSubpathDetailNoHops(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/subpath-detail", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["error"] == nil { + t.Error("expected error message when no hops provided") + } +} + +func TestResolveHopsEmpty(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/resolve-hops", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + resolved, ok := body["resolved"].(map[string]interface{}) + if !ok { + t.Fatal("expected resolved map") + } + if len(resolved) != 0 { + t.Error("expected empty resolved map for no hops") + } +} + +func TestResolveHopsAmbiguous(t *testing.T) { + // Set up server with nodes that share a prefix + db := setupTestDB(t) + seedTestData(t, db) + // Add another node with same "aabb" prefix + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('aabb000000000000', 'AnotherNode', 'repeater')`) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/resolve-hops?hops=aabb", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + resolved := body["resolved"].(map[string]interface{}) + aabb := resolved["aabb"].(map[string]interface{}) + if aabb["ambiguous"] != true { + t.Error("expected ambiguous=true when multiple candidates") + } + candidates, ok := aabb["candidates"].([]interface{}) + if !ok { + t.Fatal("expected candidates array") + } + if len(candidates) < 2 { + t.Errorf("expected at least 2 candidates, got %d", len(candidates)) + } +} + +func TestResolveHopsNoMatch(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/resolve-hops?hops=zzzz", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + resolved := body["resolved"].(map[string]interface{}) + zzzz := resolved["zzzz"].(map[string]interface{}) + if zzzz["name"] != nil { + t.Error("expected nil name for unresolved hop") + } +} + +func TestAudioLabBuckets(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/audio-lab/buckets", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["buckets"] == nil { + t.Error("expected buckets") + } +} + +func TestIATACoords(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/iata-coords", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["coords"] == nil { + t.Error("expected coords") + } +} + +func TestPerfMiddlewareRecording(t *testing.T) { + _, router := setupTestServer(t) + + // Make several requests to generate perf data + for i := 0; i < 5; i++ { + req := httptest.NewRequest("GET", "/api/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + } + + // Check perf endpoint + req := httptest.NewRequest("GET", "/api/perf", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + totalReqs := body["totalRequests"].(float64) + // At least 5 health requests + 1 perf request (but perf is also counted) + if totalReqs < 5 { + t.Errorf("expected at least 5 total requests, got %v", totalReqs) + } +} + +func TestPerfMiddlewareNonAPI(t *testing.T) { + // Non-API paths should not be recorded + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/some/non/api/path", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + // No panic, no error — middleware just passes through +} + +func TestPacketsWithOrderAsc(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&order=asc", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithTypeAndRouteFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&type=4&route=1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithExpandObservations(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&expand=observations", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestConfigClientEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/config/client", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["propagationBufferMs"] == nil { + t.Error("expected propagationBufferMs") + } +} + +func TestConfigRegionsEndpoint(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/config/regions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + // Should have at least the IATA codes from seed data + if body["SJC"] == nil { + t.Error("expected SJC region") + } + if body["SFO"] == nil { + t.Error("expected SFO region") + } +} + +func TestNodeSearchEmpty(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/search?q=", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + nodes := body["nodes"].([]interface{}) + if len(nodes) != 0 { + t.Error("expected empty nodes for empty search") + } +} + +func TestNodeSearchWhitespace(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes/search?q=%20%20", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + nodes := body["nodes"].([]interface{}) + if len(nodes) != 0 { + t.Error("expected empty nodes for whitespace search") + } +} + +func TestNodeAnalyticsNoNameNode(t *testing.T) { + // Test with a node that has no name to cover the name="" branch + db := setupTestDB(t) + seedTestData(t, db) + // Insert a node without a name + db.conn.Exec(`INSERT INTO nodes (public_key, role, lat, lon, last_seen, first_seen, advert_count) + VALUES ('deadbeef12345678', NULL, 37.5, -122.0, '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4, + '{"pubKey":"deadbeef12345678","type":"ADVERT"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 11.0, -91, '["dd"]', 1736935500)`) + + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/nodes/deadbeef12345678/analytics?days=30", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["node"] == nil { + t.Error("expected node in response") + } +} + +func TestNodeHealthForNoNameNode(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + db.conn.Exec(`INSERT INTO nodes (public_key, role, last_seen, first_seen, advert_count) + VALUES ('deadbeef12345678', 'repeater', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4, + '{"pubKey":"deadbeef12345678","type":"ADVERT"}')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 11.0, -91, '["dd"]', 1736935500)`) + + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/nodes/deadbeef12345678/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +func TestPacketsWithNodeFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&node=TestRepeater", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithRegionFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10®ion=SJC", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithHashFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&hash=abc123def4567890", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithObserverFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&observer=obs1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestPacketsWithSinceUntil(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?limit=10&since=2020-01-01&until=2099-01-01", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestNodesWithRoleFilter(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes?role=repeater&limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + total := body["total"].(float64) + if total != 1 { + t.Errorf("expected 1 repeater, got %v", total) + } +} + +func TestNodesWithSortAndSearch(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/nodes?search=Test&sortBy=name&limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestGroupedPacketsWithFilters(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=10&type=4", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestConfigThemeWithCustomConfig(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{ + Port: 3000, + Branding: map[string]interface{}{ + "siteName": "CustomSite", + }, + Theme: map[string]interface{}{ + "accent": "#ff0000", + }, + Home: map[string]interface{}{ + "title": "Welcome", + }, + } + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/theme", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + branding := body["branding"].(map[string]interface{}) + if branding["siteName"] != "CustomSite" { + t.Errorf("expected CustomSite, got %v", branding["siteName"]) + } + if body["home"] == nil { + t.Error("expected home in response") + } +} + +func TestConfigCacheWithCustomTTL(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{ + Port: 3000, + CacheTTL: map[string]interface{}{ + "nodes": 60000, + }, + } + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/cache", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["nodes"] != float64(60000) { + t.Errorf("expected 60000, got %v", body["nodes"]) + } +} + +func TestConfigRegionsWithCustomRegions(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{ + Port: 3000, + Regions: map[string]string{ + "LAX": "Los Angeles", + }, + } + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/regions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["LAX"] != "Los Angeles" { + t.Errorf("expected 'Los Angeles', got %v", body["LAX"]) + } + // DB-sourced IATA codes should also appear + if body["SJC"] == nil { + t.Error("expected SJC from DB") + } +} + +func TestConfigMapWithCustomDefaults(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + cfg.MapDefaults.Center = []float64{40.0, -74.0} + cfg.MapDefaults.Zoom = 12 + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/map", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["zoom"] != float64(12) { + t.Errorf("expected zoom 12, got %v", body["zoom"]) + } +} + +func TestHandlerErrorPaths(t *testing.T) { + // Create a DB that will error on queries by dropping the view/tables + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + // Drop the view to force query errors + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + t.Run("stats error", func(t *testing.T) { + db.conn.Exec("DROP TABLE IF EXISTS transmissions") + req := httptest.NewRequest("GET", "/api/stats", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestHandlerErrorChannels(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for channels error, got %d", w.Code) + } +} + +func TestHandlerErrorTraces(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/traces/abc123def4567890", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for traces error, got %d", w.Code) + } +} + +func TestHandlerErrorObservers(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP TABLE IF EXISTS observers") + + req := httptest.NewRequest("GET", "/api/observers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for observers error, got %d", w.Code) + } +} + +func TestHandlerErrorNodes(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP TABLE IF EXISTS nodes") + + req := httptest.NewRequest("GET", "/api/nodes?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for nodes error, got %d", w.Code) + } +} + +func TestHandlerErrorNetworkStatus(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP TABLE IF EXISTS nodes") + + req := httptest.NewRequest("GET", "/api/nodes/network-status", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for network-status error, got %d", w.Code) + } +} + +func TestHandlerErrorPackets(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/packets?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for packets error, got %d", w.Code) + } +} + +func TestHandlerErrorPacketsGrouped(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/packets?limit=10&groupByHash=true", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for grouped packets error, got %d", w.Code) + } +} + +func TestHandlerErrorNodeSearch(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP TABLE IF EXISTS nodes") + + req := httptest.NewRequest("GET", "/api/nodes/search?q=test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for node search error, got %d", w.Code) + } +} + +func TestHandlerErrorTimestamps(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/packets/timestamps?since=2020-01-01", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for timestamps error, got %d", w.Code) + } +} + +func TestHandlerErrorChannelMessages(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP VIEW IF EXISTS packets_v") + + req := httptest.NewRequest("GET", "/api/channels/%23test/messages", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500 for channel messages error, got %d", w.Code) + } +} + +func TestHandlerErrorBulkHealth(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + db.conn.Exec("DROP TABLE IF EXISTS nodes") + + req := httptest.NewRequest("GET", "/api/nodes/bulk-health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 500 { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/server/websocket_test.go b/cmd/server/websocket_test.go new file mode 100644 index 00000000..0c97a3a5 --- /dev/null +++ b/cmd/server/websocket_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestHubBroadcast(t *testing.T) { + hub := NewHub() + + if hub.ClientCount() != 0 { + t.Errorf("expected 0 clients, got %d", hub.ClientCount()) + } + + // Create a test server with WebSocket endpoint + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hub.ServeWS(w, r) + })) + defer srv.Close() + + // Connect a WebSocket client + wsURL := "ws" + srv.URL[4:] // replace http with ws + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial error: %v", err) + } + defer conn.Close() + + // Wait for registration + time.Sleep(50 * time.Millisecond) + + if hub.ClientCount() != 1 { + t.Errorf("expected 1 client, got %d", hub.ClientCount()) + } + + // Broadcast a message + hub.Broadcast(map[string]interface{}{ + "type": "packet", + "data": map[string]interface{}{"id": 1, "hash": "test123"}, + }) + + // Read the message + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read error: %v", err) + } + if len(msg) == 0 { + t.Error("expected non-empty message") + } + + // Disconnect + conn.Close() + time.Sleep(100 * time.Millisecond) +} + +func TestPollerCreation(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + hub := NewHub() + + poller := NewPoller(db, hub, 100*time.Millisecond) + if poller == nil { + t.Fatal("expected poller") + } + + // Start and stop + go poller.Start() + time.Sleep(200 * time.Millisecond) + poller.Stop() +} + +func TestHubMultipleClients(t *testing.T) { + hub := NewHub() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hub.ServeWS(w, r) + })) + defer srv.Close() + + wsURL := "ws" + srv.URL[4:] + + // Connect two clients + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial error: %v", err) + } + defer conn1.Close() + + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial error: %v", err) + } + defer conn2.Close() + + time.Sleep(100 * time.Millisecond) + + if hub.ClientCount() != 2 { + t.Errorf("expected 2 clients, got %d", hub.ClientCount()) + } + + // Broadcast and both should receive + hub.Broadcast(map[string]interface{}{"type": "test", "data": "hello"}) + + conn1.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg1, err := conn1.ReadMessage() + if err != nil { + t.Fatalf("conn1 read error: %v", err) + } + if len(msg1) == 0 { + t.Error("expected non-empty message on conn1") + } + + conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg2, err := conn2.ReadMessage() + if err != nil { + t.Fatalf("conn2 read error: %v", err) + } + if len(msg2) == 0 { + t.Error("expected non-empty message on conn2") + } + + // Disconnect one + conn1.Close() + time.Sleep(100 * time.Millisecond) + + // Remaining client should still work + hub.Broadcast(map[string]interface{}{"type": "test2"}) + + conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg3, err := conn2.ReadMessage() + if err != nil { + t.Fatalf("conn2 read error after disconnect: %v", err) + } + if len(msg3) == 0 { + t.Error("expected non-empty message") + } +} + +func TestBroadcastFullBuffer(t *testing.T) { + hub := NewHub() + + // Create a client with tiny buffer (1) + client := &Client{ + send: make(chan []byte, 1), + } + hub.mu.Lock() + hub.clients[client] = true + hub.mu.Unlock() + + // Fill the buffer + client.send <- []byte("first") + + // This broadcast should drop the message (buffer full) + hub.Broadcast(map[string]interface{}{"type": "dropped"}) + + // Channel should still only have the first message + select { + case msg := <-client.send: + if string(msg) != "first" { + t.Errorf("expected 'first', got %s", string(msg)) + } + default: + t.Error("expected message in channel") + } + + // Clean up + hub.mu.Lock() + delete(hub.clients, client) + hub.mu.Unlock() +} + +func TestBroadcastMarshalError(t *testing.T) { + hub := NewHub() + + // Marshal error: channels can't be marshaled + hub.Broadcast(make(chan int)) + // Should not panic — just log and return +} + +func TestPollerBroadcastsNewData(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + hub := NewHub() + + // Create a client to receive broadcasts + client := &Client{ + send: make(chan []byte, 256), + } + hub.mu.Lock() + hub.clients[client] = true + hub.mu.Unlock() + + poller := NewPoller(db, hub, 50*time.Millisecond) + go poller.Start() + + // Insert new data to trigger broadcast + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) + VALUES ('EEFF', 'newhash123456789', '2026-01-16T10:00:00Z', 1, 4)`) + + time.Sleep(200 * time.Millisecond) + poller.Stop() + + // Check if client received broadcast + select { + case msg := <-client.send: + if len(msg) == 0 { + t.Error("expected non-empty broadcast message") + } + default: + // Might not have received due to timing + } + + // Clean up + hub.mu.Lock() + delete(hub.clients, client) + hub.mu.Unlock() +} + +func TestHubRegisterUnregister(t *testing.T) { + hub := NewHub() + + client := &Client{ + send: make(chan []byte, 256), + } + + hub.Register(client) + if hub.ClientCount() != 1 { + t.Errorf("expected 1 client after register, got %d", hub.ClientCount()) + } + + hub.Unregister(client) + if hub.ClientCount() != 0 { + t.Errorf("expected 0 clients after unregister, got %d", hub.ClientCount()) + } + + // Unregister again should be safe + hub.Unregister(client) + if hub.ClientCount() != 0 { + t.Errorf("expected 0 clients, got %d", hub.ClientCount()) + } +}