mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-30 04:14:11 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user