test: add comprehensive Go test coverage for ingestor (80%) and server (90%)

- ingestor: add config_test.go (LoadConfig, env overrides, legacy MQTT)
- ingestor: add main_test.go (toFloat64, firstNonEmpty, handleMessage, advertRole)
- ingestor: extend decoder_test.go (short buffer errors, edge cases, all payload types)
- ingestor: extend db_test.go (empty hash, timestamp updates, BuildPacketData, schema)
- server: add config_test.go (LoadConfig, LoadTheme, health thresholds, ResolveDBPath)
- server: add helpers_test.go (writeJSON/Error, queryInt, mergeMap, round, percentile, spaHandler)
- server: extend db_test.go (all query functions, filters, channel messages, node health)
- server: extend routes_test.go (all endpoints, error paths, analytics, observer analytics)
- server: extend websocket_test.go (multi-client, buffer full, poller cycle)

Coverage: ingestor 48% -> 80%, server 52% -> 90%

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-27 00:07:44 -07:00
parent 48328e2cb3
commit e89c2bfe1f
9 changed files with 5432 additions and 0 deletions
+270
View File
@@ -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)
}
}
+313
View File
@@ -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 {
+558
View File
@@ -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"
+494
View File
@@ -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)
}
})
}
}
+314
View File
@@ -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())
}
})
}
File diff suppressed because it is too large Load Diff
+347
View File
@@ -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("<html>SPA</html>"), 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 != "<html>SPA</html>" {
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("<html>page</html>"), 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"])
}
}
File diff suppressed because it is too large Load Diff
+248
View File
@@ -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())
}
}