From 01aee47024ba8a23cfbacadc14b3ab4746c1393a Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 7 Aug 2026 16:26:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=89=80=E6=9C=89=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agents/active/active_test.go | 187 --- internal/agents/sign/sign_test.go | 336 ----- internal/blocking/blocking_cache_test.go | 102 -- internal/blocking/test_helpers_test.go | 13 - internal/config/config_test.go | 358 ------ internal/llm/state_test.go | 160 --- internal/mqtpp/builder_test.go | 215 ---- internal/mqtpp/mqtpp_test.go | 48 - internal/mqtpp/pki_test.go | 273 ---- .../runtime_settings_cache_test.go | 45 - internal/store/blocking_store_test.go | 207 --- internal/store/db_test.go | 1108 ----------------- internal/store/db_write_queue_test.go | 104 -- internal/store/map_source_store_test.go | 258 ---- internal/store/runtime_settings_store_test.go | 38 - internal/store/test_helpers_test.go | 45 - internal/store/testutil/testutil.go | 27 - internal/web/map_tile_proxy_routes_test.go | 167 --- main_test.go | 113 -- tcp_nodelay_test.go | 156 --- 20 files changed, 3960 deletions(-) delete mode 100644 internal/agents/active/active_test.go delete mode 100644 internal/agents/sign/sign_test.go delete mode 100644 internal/blocking/blocking_cache_test.go delete mode 100644 internal/blocking/test_helpers_test.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/llm/state_test.go delete mode 100644 internal/mqtpp/builder_test.go delete mode 100644 internal/mqtpp/mqtpp_test.go delete mode 100644 internal/mqtpp/pki_test.go delete mode 100644 internal/runtimesettings/runtime_settings_cache_test.go delete mode 100644 internal/store/blocking_store_test.go delete mode 100644 internal/store/db_test.go delete mode 100644 internal/store/db_write_queue_test.go delete mode 100644 internal/store/map_source_store_test.go delete mode 100644 internal/store/runtime_settings_store_test.go delete mode 100644 internal/store/test_helpers_test.go delete mode 100644 internal/store/testutil/testutil.go delete mode 100644 internal/web/map_tile_proxy_routes_test.go delete mode 100644 main_test.go delete mode 100644 tcp_nodelay_test.go diff --git a/internal/agents/active/active_test.go b/internal/agents/active/active_test.go deleted file mode 100644 index aa62da1..0000000 --- a/internal/agents/active/active_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package active - -import ( - "context" - "encoding/json" - "testing" - "time" - - "meshtastic_mqtt_server/internal/agenttool" -) - -// mockActiveStore 是用于测试的 mock store -type mockActiveStore struct { - activeNodeCount int64 - activeUserCount int64 -} - -func (m *mockActiveStore) CountActiveNodes(since time.Time) (int64, error) { - return m.activeNodeCount, nil -} - -func (m *mockActiveStore) CountActiveUsers(since time.Time) (int64, error) { - return m.activeUserCount, nil -} - -func TestActiveTool_Query(t *testing.T) { - now := time.Date(2024, 6, 23, 12, 0, 0, 0, time.UTC) - - store := &mockActiveStore{ - activeNodeCount: 25, - activeUserCount: 15, - } - - tool := &Tool{ - enabled: true, - store: store, - } - - tests := []struct { - name string - hours float64 - queryType string - expectNodes bool - expectUsers bool - expectError bool - }{ - { - name: "默认查询1小时(both)", - hours: 0, // 0 表示使用默认值 - queryType: "", - expectNodes: true, - expectUsers: true, - expectError: false, - }, - { - name: "查询6小时", - hours: 6, - queryType: "both", - expectNodes: true, - expectUsers: true, - expectError: false, - }, - { - name: "仅查询节点", - hours: 1, - queryType: "nodes", - expectNodes: true, - expectUsers: false, - expectError: false, - }, - { - name: "仅查询人数", - hours: 1, - queryType: "users", - expectNodes: false, - expectUsers: true, - expectError: false, - }, - { - name: "查询24小时(最大值)", - hours: 24, - queryType: "both", - expectNodes: true, - expectUsers: true, - expectError: false, - }, - { - name: "超过24小时应限制到24小时", - hours: 48, - queryType: "both", - expectNodes: true, - expectUsers: true, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - params := activeParams{ - Hours: tt.hours, - QueryType: tt.queryType, - } - argsJSON, _ := json.Marshal(params) - - runtime := agenttool.Runtime{Now: now} - result, err := tool.Execute(context.Background(), string(argsJSON), runtime) - - if tt.expectError && err == nil { - t.Errorf("Expected error but got none") - } - if !tt.expectError && err != nil { - t.Errorf("Unexpected error: %v", err) - } - if !tt.expectError { - t.Logf("Query result:\n%s", result) - - // 验证结果包含预期的内容 - if tt.expectNodes && result != "" { - // 应该包含节点统计 - if !contains(result, "活跃节点") { - t.Errorf("Expected result to contain node count") - } - } - if tt.expectUsers && result != "" { - // 应该包含人数统计 - if !contains(result, "活跃人数") { - t.Errorf("Expected result to contain user count") - } - } - } - }) - } -} - -func TestActiveTool_Enabled(t *testing.T) { - // 测试工具启用状态 - tests := []struct { - name string - enabled bool - store ActiveStore - expect bool - }{ - { - name: "启用且有store", - enabled: true, - store: &mockActiveStore{}, - expect: true, - }, - { - name: "启用但无store", - enabled: true, - store: nil, - expect: false, - }, - { - name: "禁用且有store", - enabled: false, - store: &mockActiveStore{}, - expect: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tool := &Tool{ - enabled: tt.enabled, - store: tt.store, - } - if tool.Enabled() != tt.expect { - t.Errorf("Expected Enabled() = %v, got %v", tt.expect, tool.Enabled()) - } - }) - } -} - -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) -} - -func containsMiddle(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/agents/sign/sign_test.go b/internal/agents/sign/sign_test.go deleted file mode 100644 index 80cddc0..0000000 --- a/internal/agents/sign/sign_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package sign - -import ( - "context" - "encoding/json" - "testing" - "time" - - "meshtastic_mqtt_server/internal/agenttool" - storepkg "meshtastic_mqtt_server/internal/store" -) - -// mockSignStore 是用于测试的 mock store -type mockSignStore struct { - signs []storepkg.SignRecord - nodeInfoMap map[string]*storepkg.NodeInfoRecord -} - -func (m *mockSignStore) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error) { - record := storepkg.SignRecord{ - NodeID: nodeID, - LongName: longName, - ShortName: shortName, - SignText: signText, - SignTime: signTime, - } - m.signs = append(m.signs, record) - return &record, nil -} - -func (m *mockSignStore) HasSignedOnDay(nodeID string, day time.Time) (bool, error) { - loc := day.Location() - if loc == nil { - loc = time.Local - } - start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) - end := start.AddDate(0, 0, 1) - - for _, sign := range m.signs { - if sign.NodeID == nodeID && sign.SignTime.After(start) && sign.SignTime.Before(end) { - return true, nil - } - } - return false, nil -} - -func (m *mockSignStore) GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error) { - return m.nodeInfoMap[nodeID], nil -} - -func (m *mockSignStore) CountSigns(opts storepkg.ListOptions) (int64, error) { - count := int64(0) - for _, sign := range m.signs { - if opts.Since != nil && sign.SignTime.Before(*opts.Since) { - continue - } - if opts.Until != nil && sign.SignTime.After(*opts.Until) { - continue - } - count++ - } - return count, nil -} - -func (m *mockSignStore) CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error) { - dayCounts := make(map[string]int64) - for _, sign := range m.signs { - if opts.Since != nil && sign.SignTime.Before(*opts.Since) { - continue - } - if opts.Until != nil && sign.SignTime.After(*opts.Until) { - continue - } - dateStr := sign.SignTime.Format("2006-01-02") - dayCounts[dateStr]++ - } - - var result []storepkg.SignDayCount - for date, count := range dayCounts { - result = append(result, storepkg.SignDayCount{Date: date, Count: count}) - } - return result, nil -} - -func (m *mockSignStore) ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error) { - var result []storepkg.SignRecord - for _, sign := range m.signs { - // 过滤 NodeID - if opts.NodeID != "" && sign.NodeID != opts.NodeID { - continue - } - // 过滤时间范围 - if opts.Since != nil && sign.SignTime.Before(*opts.Since) { - continue - } - if opts.Until != nil && sign.SignTime.After(*opts.Until) { - continue - } - result = append(result, sign) - } - // 应用 Limit - if opts.Limit > 0 && len(result) > opts.Limit { - result = result[:opts.Limit] - } - return result, nil -} - -func TestSignTool_Query(t *testing.T) { - // 创建 mock store 并添加测试数据 - now := time.Date(2024, 6, 23, 12, 0, 0, 0, time.UTC) - yesterday := now.AddDate(0, 0, -1) - twoDaysAgo := now.AddDate(0, 0, -2) - - store := &mockSignStore{ - signs: []storepkg.SignRecord{ - {NodeID: "node1", SignText: "上海-Alice-Device1签到", SignTime: now}, - {NodeID: "node2", SignText: "北京-Bob-Device2签到", SignTime: now}, - {NodeID: "node3", SignText: "深圳-Charlie-Device3签到", SignTime: yesterday}, - {NodeID: "node4", SignText: "广州-David-Device4签到", SignTime: twoDaysAgo}, - }, - nodeInfoMap: make(map[string]*storepkg.NodeInfoRecord), - } - - tool := &Tool{ - enabled: true, - store: store, - } - - tests := []struct { - name string - action string - date string - days int - expectCount int64 - expectError bool - }{ - { - name: "查询今天", - action: "query", - date: "2024-06-23", - days: 0, - expectCount: 2, - expectError: false, - }, - { - name: "查询最近3天", - action: "query", - date: "2024-06-23", - days: 3, - expectCount: 4, - expectError: false, - }, - { - name: "查询昨天", - action: "query", - date: "2024-06-22", - days: 0, - expectCount: 1, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - params := signParams{ - Action: tt.action, - Date: tt.date, - Days: tt.days, - } - argsJSON, _ := json.Marshal(params) - - runtime := agenttool.Runtime{Now: now} - result, err := tool.Execute(context.Background(), string(argsJSON), runtime) - - if tt.expectError && err == nil { - t.Errorf("Expected error but got none") - } - if !tt.expectError && err != nil { - t.Errorf("Unexpected error: %v", err) - } - if !tt.expectError { - t.Logf("Query result:\n%s", result) - } - }) - } -} - -func TestSignTool_SignAction(t *testing.T) { - now := time.Date(2024, 6, 23, 12, 0, 0, 0, time.UTC) - store := &mockSignStore{ - signs: []storepkg.SignRecord{}, - nodeInfoMap: make(map[string]*storepkg.NodeInfoRecord), - } - - tool := &Tool{ - enabled: true, - store: store, - } - - // 测试签到功能 - params := signParams{ - Action: "sign", - Region: "上海闵行", - Name: "TestUser", - Device: "TestDevice", - } - argsJSON, _ := json.Marshal(params) - - // 创建带节点上下文的 context - nodeCtx := agenttool.NodeContext{ - NodeID: "test_node_123", - LongName: "Test Node", - ShortName: "TN", - } - ctx := agenttool.WithNodeContext(context.Background(), nodeCtx) - - runtime := agenttool.Runtime{Now: now} - result, err := tool.Execute(ctx, string(argsJSON), runtime) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - t.Logf("Sign result: %s", result) - - // 验证签到记录已创建 - if len(store.signs) != 1 { - t.Errorf("Expected 1 sign record, got %d", len(store.signs)) - } -} - -func TestSignTool_CheckAction(t *testing.T) { - now := time.Date(2024, 6, 23, 12, 0, 0, 0, time.UTC) - - // 测试场景1:今天未签到 - t.Run("今天未签到", func(t *testing.T) { - store := &mockSignStore{ - signs: []storepkg.SignRecord{}, - nodeInfoMap: make(map[string]*storepkg.NodeInfoRecord), - } - - tool := &Tool{ - enabled: true, - store: store, - } - - params := signParams{ - Action: "check", - } - argsJSON, _ := json.Marshal(params) - - nodeCtx := agenttool.NodeContext{ - NodeID: "test_node_123", - LongName: "Test Node", - ShortName: "TN", - } - ctx := agenttool.WithNodeContext(context.Background(), nodeCtx) - - runtime := agenttool.Runtime{Now: now} - result, err := tool.Execute(ctx, string(argsJSON), runtime) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - t.Logf("Check result (not signed): %s", result) - - if !contains(result, "还没有签到") && !contains(result, "没有签到") { - t.Errorf("Expected result to indicate not signed yet") - } - }) - - // 测试场景2:今天已签到 - t.Run("今天已签到", func(t *testing.T) { - signTime := time.Date(2024, 6, 23, 10, 30, 45, 0, time.UTC) - store := &mockSignStore{ - signs: []storepkg.SignRecord{ - { - NodeID: "test_node_123", - SignText: "上海-TestUser-TestDevice签到", - SignTime: signTime, - }, - }, - nodeInfoMap: make(map[string]*storepkg.NodeInfoRecord), - } - - tool := &Tool{ - enabled: true, - store: store, - } - - params := signParams{ - Action: "check", - } - argsJSON, _ := json.Marshal(params) - - nodeCtx := agenttool.NodeContext{ - NodeID: "test_node_123", - LongName: "Test Node", - ShortName: "TN", - } - ctx := agenttool.WithNodeContext(context.Background(), nodeCtx) - - runtime := agenttool.Runtime{Now: now} - result, err := tool.Execute(ctx, string(argsJSON), runtime) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - t.Logf("Check result (already signed): %s", result) - - if !contains(result, "已经签到") { - t.Errorf("Expected result to indicate already signed") - } - if !contains(result, "签到时间") { - t.Errorf("Expected result to contain sign time") - } - if !contains(result, "10:30:45") { - t.Errorf("Expected result to contain the exact sign time") - } - if !contains(result, "签到内容") { - t.Errorf("Expected result to contain sign text") - } - }) -} - -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) -} - -func containsMiddle(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/blocking/blocking_cache_test.go b/internal/blocking/blocking_cache_test.go deleted file mode 100644 index 9ddffc4..0000000 --- a/internal/blocking/blocking_cache_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package blocking - -import "testing" - -func TestBlockingCacheLoadsEnabledRules(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - nodeNum := int64(305419896) - if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "enabled", true); err != nil { - t.Fatalf("CreateNodeBlocking(enabled) error = %v", err) - } - disabledNodeNum := int64(7) - if _, err := st.CreateNodeBlocking("!00000007", &disabledNodeNum, "disabled", false); err != nil { - t.Fatalf("CreateNodeBlocking(disabled) error = %v", err) - } - if _, err := st.CreateIPBlocking("192.168.1.0/24", "lan", true); err != nil { - t.Fatalf("CreateIPBlocking(cidr) error = %v", err) - } - if _, err := st.CreateIPBlocking("10.0.0.1", "disabled", false); err != nil { - t.Fatalf("CreateIPBlocking(disabled) error = %v", err) - } - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "enabled", true); err != nil { - t.Fatalf("CreateForbiddenWordBlocking(enabled) error = %v", err) - } - if _, err := st.CreateForbiddenWordBlocking("blocked", "contains", false, "disabled", false); err != nil { - t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err) - } - - cache, err := New(st) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if !cache.IsNodeBlocked("!12345678", nil) { - t.Fatal("IsNodeBlocked(enabled node id) = false, want true") - } - if !cache.IsNodeBlocked("", uint32(nodeNum)) { - t.Fatal("IsNodeBlocked(enabled node num) = false, want true") - } - if cache.IsNodeBlocked("!00000007", disabledNodeNum) { - t.Fatal("IsNodeBlocked(disabled node) = true, want false") - } - if !cache.IsIPBlocked("192.168.1.42") { - t.Fatal("IsIPBlocked(CIDR member) = false, want true") - } - if cache.IsIPBlocked("10.0.0.1") { - t.Fatal("IsIPBlocked(disabled IP) = true, want false") - } - if word, ok := cache.FindForbiddenWord("This is SPAM text"); !ok || word != "spam" { - t.Fatalf("FindForbiddenWord(case-insensitive) = %q, %v, want spam, true", word, ok) - } - if _, ok := cache.FindForbiddenWord("disabled blocked text"); ok { - t.Fatal("FindForbiddenWord(disabled word) = true, want false") - } -} - -func TestBlockingCacheIPExactAndCIDR(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateIPBlocking("127.0.0.1", "loopback", true); err != nil { - t.Fatalf("CreateIPBlocking(ip) error = %v", err) - } - if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil { - t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err) - } - cache, err := New(st) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if !cache.IsIPBlocked("127.0.0.1") { - t.Fatal("IsIPBlocked(exact IPv4) = false, want true") - } - if !cache.IsIPBlocked("2001:db8::1") { - t.Fatal("IsIPBlocked(IPv6 CIDR) = false, want true") - } - if cache.IsIPBlocked("localhost") { - t.Fatal("IsIPBlocked(hostname) = true, want false") - } -} - -func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil { - t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err) - } - cache, err := New(st) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if _, ok := cache.FindForbiddenWord("lowercase spam"); ok { - t.Fatal("FindForbiddenWord(lowercase) = true, want false") - } - if word, ok := cache.FindForbiddenWord("contains Spam"); !ok || word != "Spam" { - t.Fatalf("FindForbiddenWord(exact case) = %q, %v, want Spam, true", word, ok) - } -} diff --git a/internal/blocking/test_helpers_test.go b/internal/blocking/test_helpers_test.go deleted file mode 100644 index d1a8309..0000000 --- a/internal/blocking/test_helpers_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package blocking - -import ( - "testing" - - "meshtastic_mqtt_server/internal/store" - "meshtastic_mqtt_server/internal/store/testutil" -) - -// openTestStore 委托到 store/testutil,让本包的测试代码保持简洁。 -func openTestStore(t *testing.T) *store.Store { - return testutil.OpenStore(t) -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index d9b49a5..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadConfigCreatesDefaultFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.MQTT.Host != "0.0.0.0" { - t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host) - } - if cfg.MQTT.Port != 1883 { - t.Fatalf("port = %d, want 1883", cfg.MQTT.Port) - } - if cfg.MQTT.TLS.Enabled { - t.Fatalf("tls enabled = true, want false") - } - if cfg.Meshtastic.PSK != "AQ==" { - t.Fatalf("psk = %q, want AQ==", cfg.Meshtastic.PSK) - } - if cfg.Database.Driver != "sqlite" { - t.Fatalf("database driver = %q, want sqlite", cfg.Database.Driver) - } - if cfg.Database.SQLite.Path == "" { - t.Fatalf("sqlite path is empty") - } - if !cfg.Web.Enabled { - t.Fatalf("web enabled = false, want true") - } - if !cfg.Web.PortEnabled { - t.Fatalf("web port enabled = false, want true") - } - wantSocketEnabled := defaultWebSocketPath() != "" - if cfg.Web.SocketEnabled != wantSocketEnabled { - t.Fatalf("web socket enabled = %t, want %t", cfg.Web.SocketEnabled, wantSocketEnabled) - } - if cfg.Web.Port != 8080 { - t.Fatalf("web port = %d, want 8080", cfg.Web.Port) - } - if cfg.Web.SocketPath != defaultWebSocketPath() { - t.Fatalf("web socket path = %q, want %q", cfg.Web.SocketPath, defaultWebSocketPath()) - } - if cfg.Web.StaticDir != "./dist" { - t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir) - } - if cfg.Web.MapTileCacheDir != defaultMapTileCacheDir() { - t.Fatalf("web map tile cache dir = %q, want %q", cfg.Web.MapTileCacheDir, defaultMapTileCacheDir()) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("default config was not written: %v", err) - } -} - -func TestLoadConfigFillsMissingFields(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("mqtt:\n port: 1884\n"), 0644); err != nil { - t.Fatal(err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.MQTT.Port != 1884 { - t.Fatalf("port = %d, want 1884", cfg.MQTT.Port) - } - if cfg.MQTT.Host != "0.0.0.0" { - t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host) - } - if cfg.Meshtastic.PSK != "AQ==" { - t.Fatalf("psk = %q, want AQ==", cfg.Meshtastic.PSK) - } - if cfg.Database.Driver != "sqlite" { - t.Fatalf("database driver = %q, want sqlite", cfg.Database.Driver) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - text := string(data) - for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port_enabled:", "socket_enabled:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} { - if !strings.Contains(text, want) { - t.Fatalf("completed config missing %q in:\n%s", want, text) - } - } -} - -func TestLoadConfigPreservesExplicitFalse(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - content := "mqtt:\n host: 127.0.0.1\n port: 1885\n tls:\n enabled: false\n cert_file: cert.pem\n key_file: key.pem\nmeshtastic:\n psk: AQ==\ndatabase:\n driver: sqlite\n sqlite:\n path: test.db\n mysql:\n dsn: \"\"\n" - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.MQTT.TLS.Enabled { - t.Fatalf("tls enabled = true, want explicit false") - } - if cfg.MQTT.TLS.CertFile != "cert.pem" || cfg.MQTT.TLS.KeyFile != "key.pem" { - t.Fatalf("tls paths = %q/%q, want cert.pem/key.pem", cfg.MQTT.TLS.CertFile, cfg.MQTT.TLS.KeyFile) - } -} - -func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - content := "web:\n enabled: false\n port_enabled: false\n socket_enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n" - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.Web.Enabled { - t.Fatalf("web enabled = true, want explicit false") - } - if cfg.Web.PortEnabled || cfg.Web.SocketEnabled { - t.Fatalf("web listener enabled = %t/%t, want explicit false/false", cfg.Web.PortEnabled, cfg.Web.SocketEnabled) - } - if cfg.Web.Host != "127.0.0.1" || cfg.Web.Port != 8081 || cfg.Web.StaticDir != "./public" { - t.Fatalf("web config = %#v", cfg.Web) - } -} - -func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - content := "mqtt:\n port: [\n" - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - _, err := Load(path) - if err == nil { - t.Fatalf("Load() error = nil, want parse error") - } - data, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(data) != content { - t.Fatalf("malformed config was overwritten: %q", string(data)) - } -} - -func TestDefaultConfigDirForGOOS(t *testing.T) { - wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go") - for _, goos := range []string{"windows", "darwin"} { - path := defaultConfigDirForGOOS(goos) - if path != wantRelative { - t.Fatalf("%s config dir = %q, want %q", goos, path, wantRelative) - } - } - - linuxPath := defaultConfigDirForGOOS("linux") - wantLinux := filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go") - if linuxPath != wantLinux { - t.Fatalf("linux config dir = %q, want %q", linuxPath, wantLinux) - } -} - -func TestDefaultMapTileCacheDirForGOOS(t *testing.T) { - wantRelative := filepath.Join(".", "win", "srv", "mesh_mqtt_go") - for _, goos := range []string{"windows", "darwin"} { - path := defaultMapTileCacheDirForGOOS(goos) - if path != wantRelative { - t.Fatalf("%s map tile cache dir = %q, want %q", goos, path, wantRelative) - } - } - - linuxPath := defaultMapTileCacheDirForGOOS("linux") - wantLinux := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") - if linuxPath != wantLinux { - t.Fatalf("linux map tile cache dir = %q, want %q", linuxPath, wantLinux) - } -} - -func TestDefaultWebSocketPathForGOOS(t *testing.T) { - if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" { - t.Fatalf("windows web socket path = %q, want empty", windowsPath) - } - - darwinPath := defaultWebSocketPathForGOOS("darwin") - wantDarwin := filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock") - if darwinPath != wantDarwin { - t.Fatalf("darwin web socket path = %q, want %q", darwinPath, wantDarwin) - } - - linuxPath := defaultWebSocketPathForGOOS("linux") - want := filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock") - if linuxPath != want { - t.Fatalf("linux web socket path = %q, want %q", linuxPath, want) - } -} - -func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) { - cfg := Default() - cfg.Web.SocketPath = filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock") - if !ClearWebSocketPathOnUnsupportedGOOS(cfg, "windows") { - t.Fatalf("ClearWebSocketPathOnUnsupportedGOOS() = false, want true") - } - if cfg.Web.SocketPath != "" { - t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath) - } - if cfg.Web.SocketEnabled { - t.Fatalf("windows web socket enabled = true, want false") - } - - cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock" - if ClearWebSocketPathOnUnsupportedGOOS(cfg, "linux") { - t.Fatalf("linux ClearWebSocketPathOnUnsupportedGOOS() = true, want false") - } - if cfg.Web.SocketPath == "" { - t.Fatalf("linux web socket path was cleared") - } -} - -func TestDefaultSQLitePathForGOOS(t *testing.T) { - wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db") - for _, goos := range []string{"windows", "darwin"} { - path := defaultSQLitePathForGOOS(goos) - if path != wantRelative { - t.Fatalf("%s sqlite path = %q, want %q", goos, path, wantRelative) - } - } - - linuxPath := defaultSQLitePathForGOOS("linux") - want := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db") - if linuxPath != want { - t.Fatalf("linux sqlite path = %q, want %q", linuxPath, want) - } -} - -func TestValidateConfigDatabase(t *testing.T) { - cfg := Default() - cfg.Database.Driver = "postgres" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") { - t.Fatalf("invalid driver error = %v, want database.driver error", err) - } - - cfg = Default() - cfg.Database.SQLite.Path = "" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") { - t.Fatalf("missing sqlite path error = %v, want database.sqlite.path error", err) - } - - cfg = Default() - cfg.Database.Driver = "mysql" - cfg.Database.MySQL.DSN = "" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") { - t.Fatalf("missing mysql dsn error = %v, want database.mysql.dsn error", err) - } -} - -func TestValidateConfigWeb(t *testing.T) { - cfg := Default() - cfg.Web.Port = 0 - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web port") { - t.Fatalf("invalid web port error = %v, want web port error", err) - } - - cfg = Default() - cfg.Web.PortEnabled = false - cfg.Web.Port = 0 - if err := Validate(cfg); err != nil { - t.Fatalf("disabled web port with invalid port error = %v, want nil", err) - } - - cfg = Default() - cfg.Web.SocketEnabled = false - cfg.Web.SocketPath = "" - if err := Validate(cfg); err != nil { - t.Fatalf("disabled web socket with empty path error = %v, want nil", err) - } - - cfg = Default() - cfg.Web.PortEnabled = false - cfg.Web.SocketEnabled = true - cfg.Web.SocketPath = "" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") { - t.Fatalf("missing web socket path error = %v, want web.socket_path error", err) - } - - cfg = Default() - cfg.Web.PortEnabled = false - cfg.Web.SocketEnabled = false - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") { - t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err) - } - - cfg = Default() - cfg.Web.StaticDir = "" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") { - t.Fatalf("missing web static dir error = %v, want web.static_dir error", err) - } - - cfg = Default() - cfg.Web.MapTileCacheDir = "" - if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") { - t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err) - } - - cfg = Default() - cfg.Web.Enabled = false - cfg.Web.PortEnabled = false - cfg.Web.SocketEnabled = false - cfg.Web.Port = 0 - cfg.Web.StaticDir = "" - if err := Validate(cfg); err != nil { - t.Fatalf("disabled web validate error = %v, want nil", err) - } -} - -func TestBuildTLSConfigDisabled(t *testing.T) { - cfg, err := BuildTLS(TLSConfig{}) - if err != nil { - t.Fatalf("BuildTLS() error = %v", err) - } - if cfg != nil { - t.Fatalf("BuildTLS() = %#v, want nil", cfg) - } -} - -func TestBuildTLSConfigRequiresCertAndKey(t *testing.T) { - _, err := BuildTLS(TLSConfig{Enabled: true}) - if err == nil || !strings.Contains(err.Error(), "cert_file") { - t.Fatalf("missing cert error = %v, want cert_file error", err) - } - - _, err = BuildTLS(TLSConfig{Enabled: true, CertFile: "cert.pem"}) - if err == nil || !strings.Contains(err.Error(), "key_file") { - t.Fatalf("missing key error = %v, want key_file error", err) - } -} diff --git a/internal/llm/state_test.go b/internal/llm/state_test.go deleted file mode 100644 index 11b9d7f..0000000 --- a/internal/llm/state_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package llm - -import ( - "testing" -) - -func TestUpdateProvider(t *testing.T) { - // Create initial state with one provider - configs := []ProviderConfig{ - { - Name: "test-provider", - Active: true, - APIKey: "test-key", - BaseURL: "https://test.example.com", - Model: "test-model", - Timeout: 120, - ContextWindowTokens: 4096, - }, - } - - state, err := NewState(configs) - if err != nil { - t.Fatalf("failed to create state: %v", err) - } - - // Get the initial profile - profile := state.ActiveProfile() - if profile.Config.APIKey != "test-key" { - t.Errorf("expected APIKey 'test-key', got '%s'", profile.Config.APIKey) - } - - // Update the provider with new config - updatedConfig := ProviderConfig{ - Name: "test-provider", - Active: true, - APIKey: "new-key", - BaseURL: "https://new.example.com", - Model: "new-model", - Timeout: 60, - ContextWindowTokens: 8192, - } - - err = state.UpdateProvider(updatedConfig) - if err != nil { - t.Fatalf("failed to update provider: %v", err) - } - - // Verify the update - profile = state.ActiveProfile() - if profile.Config.APIKey != "new-key" { - t.Errorf("expected updated APIKey 'new-key', got '%s'", profile.Config.APIKey) - } - if profile.Config.BaseURL != "https://new.example.com" { - t.Errorf("expected updated BaseURL 'https://new.example.com', got '%s'", profile.Config.BaseURL) - } - if profile.Config.Model != "new-model" { - t.Errorf("expected updated Model 'new-model', got '%s'", profile.Config.Model) - } - if profile.Config.Timeout != 60 { - t.Errorf("expected updated Timeout 60, got %d", profile.Config.Timeout) - } -} - -func TestAddProvider(t *testing.T) { - // Create initial state with one provider - configs := []ProviderConfig{ - { - Name: "provider1", - Active: true, - APIKey: "key1", - BaseURL: "https://example1.com", - Model: "model1", - Timeout: 120, - ContextWindowTokens: 4096, - }, - } - - state, err := NewState(configs) - if err != nil { - t.Fatalf("failed to create state: %v", err) - } - - // Add a second provider - newConfig := ProviderConfig{ - Name: "provider2", - Active: false, - APIKey: "key2", - BaseURL: "https://example2.com", - Model: "model2", - Timeout: 60, - ContextWindowTokens: 8192, - } - - err = state.AddProvider(newConfig) - if err != nil { - t.Fatalf("failed to add provider: %v", err) - } - - // Verify the new provider exists - profile, err := state.GetProfile("provider2") - if err != nil { - t.Fatalf("failed to get provider2: %v", err) - } - if profile.Config.Name != "provider2" { - t.Errorf("expected name 'provider2', got '%s'", profile.Config.Name) - } - - // Verify active provider is still provider1 - activeProfile := state.ActiveProfile() - if activeProfile.Config.Name != "provider1" { - t.Errorf("expected active provider 'provider1', got '%s'", activeProfile.Config.Name) - } -} - -func TestRemoveProvider(t *testing.T) { - // Create initial state with two providers - configs := []ProviderConfig{ - { - Name: "provider1", - Active: true, - APIKey: "key1", - BaseURL: "https://example1.com", - Model: "model1", - Timeout: 120, - ContextWindowTokens: 4096, - }, - { - Name: "provider2", - Active: false, - APIKey: "key2", - BaseURL: "https://example2.com", - Model: "model2", - Timeout: 60, - ContextWindowTokens: 8192, - }, - } - - state, err := NewState(configs) - if err != nil { - t.Fatalf("failed to create state: %v", err) - } - - // Remove provider2 - err = state.RemoveProvider("provider2") - if err != nil { - t.Fatalf("failed to remove provider: %v", err) - } - - // Verify provider2 is gone - _, err = state.GetProfile("provider2") - if err == nil { - t.Error("expected error when getting removed provider, got nil") - } - - // Try to remove the last provider (should fail) - err = state.RemoveProvider("provider1") - if err == nil { - t.Error("expected error when removing last provider, got nil") - } -} diff --git a/internal/mqtpp/builder_test.go b/internal/mqtpp/builder_test.go deleted file mode 100644 index c69d5e1..0000000 --- a/internal/mqtpp/builder_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package mqtpp - -import "testing" - -func TestBuildTextMessageServiceEnvelopeRoundTrip(t *testing.T) { - key, err := ExpandPSK("AQ==") - if err != nil { - t.Fatalf("ExpandPSK() error = %v", err) - } - - raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{ - PacketBuildOptions: PacketBuildOptions{ - FromNodeNum: 0x12345678, - ToNodeNum: NodeNumBroadcast, - PacketID: 0x87654321, - ChannelID: "LongFast", - GatewayID: "!12345678", - PSK: key, - Encrypt: true, - ViaMQTT: true, - }, - Text: "hello from bot", - }) - if err != nil { - t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err) - } - - valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{}) - if !valid { - t.Fatalf("MQTTPP() valid = false, record = %#v", record) - } - if record["type"] != "text_message" { - t.Fatalf("record type = %v", record["type"]) - } - if record["text"] != "hello from bot" { - t.Fatalf("text = %v", record["text"]) - } - if record["from_num"] != uint32(0x12345678) { - t.Fatalf("from_num = %v", record["from_num"]) - } - if record["packet_to_num"] != uint32(NodeNumBroadcast) { - t.Fatalf("packet_to_num = %v", record["packet_to_num"]) - } - if record["decrypt_success"] != true { - t.Fatalf("decrypt_success = %v", record["decrypt_success"]) - } -} - -func TestBuildTextMessageServiceEnvelopeDirectRoundTrip(t *testing.T) { - key, err := ExpandPSK("AQ==") - if err != nil { - t.Fatalf("ExpandPSK() error = %v", err) - } - - raw, err := BuildTextMessageServiceEnvelope(TextMessageBuildOptions{ - PacketBuildOptions: PacketBuildOptions{ - FromNodeNum: 0x12345678, - ToNodeNum: 0x10203040, - PacketID: 0x11111111, - ChannelID: "LongFast", - GatewayID: "!12345678", - PSK: key, - Encrypt: true, - ViaMQTT: true, - }, - Text: "direct hello", - }) - if err != nil { - t.Fatalf("BuildTextMessageServiceEnvelope() error = %v", err) - } - - valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{}) - if !valid { - t.Fatalf("MQTTPP() valid = false, record = %#v", record) - } - if record["text"] != "direct hello" { - t.Fatalf("text = %v", record["text"]) - } - if record["packet_to"] != "!10203040" { - t.Fatalf("packet_to = %v", record["packet_to"]) - } - if record["packet_to_num"] != uint32(0x10203040) { - t.Fatalf("packet_to_num = %v", record["packet_to_num"]) - } -} - -func TestBuildNodeInfoServiceEnvelopeRoundTrip(t *testing.T) { - key, err := ExpandPSK("AQ==") - if err != nil { - t.Fatalf("ExpandPSK() error = %v", err) - } - - raw, err := BuildNodeInfoServiceEnvelope(NodeInfoBuildOptions{ - PacketBuildOptions: PacketBuildOptions{ - FromNodeNum: 0x12345678, - ToNodeNum: NodeNumBroadcast, - PacketID: 0x22222222, - ChannelID: "LongFast", - GatewayID: "!12345678", - PSK: key, - Encrypt: true, - ViaMQTT: true, - }, - NodeID: "!12345678", - LongName: "MQTT Bot", - ShortName: "BT", - HWModel: 255, - Role: 0, - IsLicensed: false, - PublicKey: []byte{1, 2, 3}, - }) - if err != nil { - t.Fatalf("BuildNodeInfoServiceEnvelope() error = %v", err) - } - - valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{}) - if !valid { - t.Fatalf("MQTTPP() valid = false, record = %#v", record) - } - if record["type"] != "nodeinfo" { - t.Fatalf("record type = %v", record["type"]) - } - if record["long_name"] != "MQTT Bot" { - t.Fatalf("long_name = %v", record["long_name"]) - } - if record["short_name"] != "BT" { - t.Fatalf("short_name = %v", record["short_name"]) - } - if record["hw_model"] != "PRIVATE_HW" { - t.Fatalf("hw_model = %v", record["hw_model"]) - } - if record["role"] != "CLIENT" { - t.Fatalf("role = %v", record["role"]) - } - if record["is_licensed"] != false { - t.Fatalf("is_licensed = %v", record["is_licensed"]) - } - if record["public_key"] != "010203" { - t.Fatalf("public_key = %v", record["public_key"]) - } -} - -func TestBuildNodeInfoTruncatesNanopbStrings(t *testing.T) { - key, err := ExpandPSK("AQ==") - if err != nil { - t.Fatalf("ExpandPSK() error = %v", err) - } - - raw, err := BuildNodeInfoServiceEnvelope(NodeInfoBuildOptions{ - PacketBuildOptions: PacketBuildOptions{FromNodeNum: 0x12345678, ToNodeNum: NodeNumBroadcast, PacketID: 0x33333333, ChannelID: "LongFast", GatewayID: "!12345678", PSK: key, Encrypt: true, ViaMQTT: true}, - NodeID: "!12345678", - LongName: "这是一个非常非常非常非常长的机器人节点名称", - ShortName: "机器人", - }) - if err != nil { - t.Fatalf("BuildNodeInfoServiceEnvelope() error = %v", err) - } - valid, _, record := MQTTPP("msh/2/e/LongFast/!12345678", raw, key, Options{}) - if !valid { - t.Fatalf("MQTTPP() valid = false, record = %#v", record) - } - if len([]byte(record["long_name"].(string))) > 40 { - t.Fatalf("long_name byte length = %d", len([]byte(record["long_name"].(string)))) - } - if len([]byte(record["short_name"].(string))) > 5 { - t.Fatalf("short_name byte length = %d", len([]byte(record["short_name"].(string)))) - } -} - -func TestBuildAckServiceEnvelopeRoundTrip(t *testing.T) { - key, err := ExpandPSK("AQ==") - if err != nil { - t.Fatalf("ExpandPSK: %v", err) - } - const requestID uint32 = 0xabcd1234 - raw, err := BuildAckServiceEnvelope(AckBuildOptions{ - PacketBuildOptions: PacketBuildOptions{ - FromNodeNum: 0x10101010, - ToNodeNum: 0x20202020, - PacketID: 0x30303030, - ChannelID: "LongFast", - GatewayID: "!10101010", - PSK: key, - Encrypt: true, - ViaMQTT: true, - }, - RequestID: requestID, - }) - if err != nil { - t.Fatalf("BuildAckServiceEnvelope: %v", err) - } - valid, _, record := MQTTPP("msh/2/e/LongFast/!10101010", raw, key, Options{}) - if !valid { - t.Fatalf("MQTTPP not valid: %#v", record) - } - if record["portnum"] != "ROUTING_APP" { - t.Fatalf("portnum = %v", record["portnum"]) - } - if record["type"] != "routing" { - t.Fatalf("type = %v", record["type"]) - } -} - -func TestParseNodeID(t *testing.T) { - num, err := ParseNodeID("!1234abcd") - if err != nil { - t.Fatalf("ParseNodeID() error = %v", err) - } - if num != 0x1234abcd { - t.Fatalf("num = %#x", num) - } - if NodeNumToID(num) != "!1234abcd" { - t.Fatalf("NodeNumToID() = %s", NodeNumToID(num)) - } -} diff --git a/internal/mqtpp/mqtpp_test.go b/internal/mqtpp/mqtpp_test.go deleted file mode 100644 index 501a74b..0000000 --- a/internal/mqtpp/mqtpp_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package mqtpp - -import ( - "testing" - - "google.golang.org/protobuf/encoding/protowire" -) - -func TestMQTTPPEncryptedPacketDefaultRejected(t *testing.T) { - raw := encryptedServiceEnvelopeTestPayload() - valid, payload, record := MQTTPP("msh/test", raw, nil, Options{}) - if valid { - t.Fatalf("valid = true, want false") - } - if payload != nil { - t.Fatalf("payload = %v, want nil", payload) - } - if record["type"] != "encrypted_packet" { - t.Fatalf("type = %v, want encrypted_packet", record["type"]) - } - if record["error"] != "cannot be decrypted" { - t.Fatalf("error = %v, want cannot be decrypted", record["error"]) - } -} - -func TestMQTTPPEncryptedPacketAllowed(t *testing.T) { - raw := encryptedServiceEnvelopeTestPayload() - valid, payload, record := MQTTPP("msh/test", raw, nil, Options{AllowEncryptedForwarding: true}) - if !valid { - t.Fatalf("valid = false, want true: %+v", record) - } - if string(payload) != string(raw) { - t.Fatalf("payload = %v, want raw payload", payload) - } - if record["type"] != "encrypted_packet" { - t.Fatalf("type = %v, want encrypted_packet", record["type"]) - } - if record["error"] != nil { - t.Fatalf("error = %v, want nil", record["error"]) - } -} - -func encryptedServiceEnvelopeTestPayload() []byte { - packet := protowire.AppendTag(nil, 5, protowire.BytesType) - packet = protowire.AppendBytes(packet, []byte{1, 2, 3, 4}) - envelope := protowire.AppendTag(nil, 1, protowire.BytesType) - return protowire.AppendBytes(envelope, packet) -} diff --git a/internal/mqtpp/pki_test.go b/internal/mqtpp/pki_test.go deleted file mode 100644 index 016088d..0000000 --- a/internal/mqtpp/pki_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package mqtpp - -import ( - "bytes" - "crypto/ecdh" - "crypto/rand" - "encoding/binary" - "testing" - - "google.golang.org/protobuf/encoding/protowire" -) - -func TestBuildPKITextMessageRoundTrip(t *testing.T) { - curve := ecdh.X25519() - senderPriv, err := curve.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generate sender key: %v", err) - } - recipientPriv, err := curve.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generate recipient key: %v", err) - } - - const text = "hello over PKI 你好" - const fromNum uint32 = 0x12345678 - const toNum uint32 = 0xa1b2c3d4 - const packetID uint32 = 0xdeadbeef - - raw, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{ - FromNodeNum: fromNum, - ToNodeNum: toNum, - PacketID: packetID, - GatewayID: NodeNumToID(fromNum), - ViaMQTT: true, - SenderPrivate: senderPriv.Bytes(), - RecipientPub: recipientPriv.PublicKey().Bytes(), - SenderPublic: senderPriv.PublicKey().Bytes(), - Text: text, - }) - if err != nil { - t.Fatalf("BuildPKITextMessageServiceEnvelope: %v", err) - } - - env, err := parseServiceEnvelope(raw) - if err != nil { - t.Fatalf("parseServiceEnvelope: %v", err) - } - if env.ChannelID != PKIChannelID { - t.Fatalf("channel_id = %q want %q", env.ChannelID, PKIChannelID) - } - if env.GatewayID != NodeNumToID(fromNum) { - t.Fatalf("gateway_id = %q", env.GatewayID) - } - pkt := env.Packet - if pkt.From != fromNum || pkt.To != toNum || pkt.ID != packetID { - t.Fatalf("packet header mismatch: %+v", pkt) - } - if !pkt.PKIEncrypted { - t.Fatalf("pki_encrypted = false") - } - if !pkt.ViaMQTT { - t.Fatalf("via_mqtt = false") - } - if pkt.Channel != 0 { - t.Fatalf("channel = %d want 0", pkt.Channel) - } - if pkt.PayloadVariant != "encrypted" || len(pkt.Encrypted) <= pkcOverhead { - t.Fatalf("encrypted payload missing: %+v", pkt) - } - - // 收件人用对端私钥 + 发件人公钥推导共享密钥并解密 - sharedKey, err := pkiSharedKey(recipientPriv.Bytes(), senderPriv.PublicKey().Bytes()) - if err != nil { - t.Fatalf("pkiSharedKey: %v", err) - } - encryptedLen := len(pkt.Encrypted) - pkcOverhead - ciphertext := pkt.Encrypted[:encryptedLen] - auth := pkt.Encrypted[encryptedLen : encryptedLen+8] - extraNonce := binary.LittleEndian.Uint32(pkt.Encrypted[encryptedLen+8:]) - plaintext, err := aesCCMDecrypt(sharedKey, pkiNonce(packetID, fromNum, extraNonce), ciphertext, auth) - if err != nil { - t.Fatalf("aesCCMDecrypt: %v", err) - } - data, err := parseDataPacket(plaintext) - if err != nil { - t.Fatalf("parseDataPacket: %v", err) - } - if data.Portnum != textMessageApp { - t.Fatalf("portnum = %d", data.Portnum) - } - if string(data.Payload) != text { - t.Fatalf("text = %q want %q", string(data.Payload), text) - } - - // 同样用 MQTTPP 解析路径:PKI 包对外应被识别为 encrypted_packet(无法解密), - // 但用错的 PSK 不应误报“channel hash mismatch” 之外的奇怪错误。 - dummyPSK, _ := ExpandPSK("AQ==") - _, _, record := MQTTPP("msh/2/e/PKI/!12345678", raw, dummyPSK, Options{AllowEncryptedForwarding: true}) - if record["channel_id"] != PKIChannelID { - t.Fatalf("MQTTPP record channel_id = %v", record["channel_id"]) - } - if record["pki_encrypted"] != true { - t.Fatalf("pki_encrypted record = %v", record["pki_encrypted"]) - } -} - -func TestPKINonceLayoutMatchesFirmware(t *testing.T) { - // 复刻 firmware initNonce(fromNode, packetId, extraNonce) 期望的字节布局: - // nonce[0..8) = packetId(uint64 LE) - // nonce[4..8) 被 extraNonce(uint32 LE) 覆盖(当 extraNonce != 0) - // nonce[8..12) = fromNode(uint32 LE) - // nonce[12] = 0 - got := pkiNonce(0xaabbccdd, 0x11223344, 0x55667788) - want := []byte{ - 0xdd, 0xcc, 0xbb, 0xaa, // packetId low 4 bytes,未被 extraNonce 覆盖前 - 0x88, 0x77, 0x66, 0x55, // extraNonce 覆盖 nonce[4..8) - 0x44, 0x33, 0x22, 0x11, // fromNode - 0x00, - } - if !bytes.Equal(got, want) { - t.Fatalf("pkiNonce = % x\nwant % x", got, want) - } -} - -func TestBuildPKITextMessageRejectsBroadcast(t *testing.T) { - curve := ecdh.X25519() - priv, _ := curve.GenerateKey(rand.Reader) - pub, _ := curve.GenerateKey(rand.Reader) - if _, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{ - FromNodeNum: 0x1, - ToNodeNum: NodeNumBroadcast, - PacketID: 0x2, - SenderPrivate: priv.Bytes(), - RecipientPub: pub.PublicKey().Bytes(), - Text: "hi", - }); err == nil { - t.Fatalf("expected error for broadcast destination") - } -} - -// 确认 MeshPacket 中确实带上 pki_encrypted (tag 17) 与 public_key (tag 16) -func TestBuildPKIMeshPacketTags(t *testing.T) { - encrypted := []byte{0x01, 0x02, 0x03} - pub := make([]byte, 32) - for i := range pub { - pub[i] = byte(i) - } - raw := buildPKIMeshPacket(0x11, 0x22, 0x33, true, encrypted, pub) - tags := map[protowire.Number]bool{} - if err := walkFields(raw, func(num protowire.Number, _ protowire.Type, _ any) error { - tags[num] = true - return nil - }); err != nil { - t.Fatalf("walkFields: %v", err) - } - for _, want := range []protowire.Number{1, 2, 5, 6, 14, 16, 17} { - if !tags[want] { - t.Fatalf("missing tag %d", want) - } - } -} - -// 端到端:发送方构造 PKI 包,接收方通过 PKIKeyResolver 解密并还原文本消息记录。 -func TestMQTTPPDecryptsPKIWithResolver(t *testing.T) { - curve := ecdh.X25519() - senderPriv, _ := curve.GenerateKey(rand.Reader) - recipientPriv, _ := curve.GenerateKey(rand.Reader) - - const text = "hello PKI inbound" - const fromNum uint32 = 0xaaaa1111 - const toNum uint32 = 0xbbbb2222 - const packetID uint32 = 0x77777777 - - raw, err := BuildPKITextMessageServiceEnvelope(PKITextMessageBuildOptions{ - FromNodeNum: fromNum, - ToNodeNum: toNum, - PacketID: packetID, - GatewayID: NodeNumToID(fromNum), - ViaMQTT: true, - SenderPrivate: senderPriv.Bytes(), - RecipientPub: recipientPriv.PublicKey().Bytes(), - SenderPublic: senderPriv.PublicKey().Bytes(), - Text: text, - }) - if err != nil { - t.Fatalf("build: %v", err) - } - - resolver := func(to, from uint32) ([]byte, []byte, bool) { - if to != toNum || from != fromNum { - return nil, nil, false - } - return recipientPriv.Bytes(), senderPriv.PublicKey().Bytes(), true - } - dummyPSK, _ := ExpandPSK("AQ==") - valid, _, record := MQTTPP("msh/2/e/PKI/!aaaa1111", raw, dummyPSK, Options{PKIKeyResolver: resolver}) - if !valid { - t.Fatalf("MQTTPP not valid: %#v", record) - } - if record["type"] != "text_message" { - t.Fatalf("type = %v, want text_message", record["type"]) - } - if record["text"] != text { - t.Fatalf("text = %v", record["text"]) - } - if record["pki_encrypted"] != true { - t.Fatalf("pki_encrypted = %v", record["pki_encrypted"]) - } -} - -func TestBuildPKIAckRoundTrip(t *testing.T) { - curve := ecdh.X25519() - botPriv, _ := curve.GenerateKey(rand.Reader) - devicePriv, _ := curve.GenerateKey(rand.Reader) - - const fromNum uint32 = 0x0000beef // bot - const toNum uint32 = 0xfeed0000 // 原 device - const ackPacketID uint32 = 0xaaaa5555 - const requestID uint32 = 0xdeadbeef - - raw, err := BuildPKIAckServiceEnvelope(PKIAckBuildOptions{ - FromNodeNum: fromNum, - ToNodeNum: toNum, - PacketID: ackPacketID, - RequestID: requestID, - GatewayID: NodeNumToID(fromNum), - ViaMQTT: true, - SenderPrivate: botPriv.Bytes(), - RecipientPub: devicePriv.PublicKey().Bytes(), - SenderPublic: botPriv.PublicKey().Bytes(), - }) - if err != nil { - t.Fatalf("BuildPKIAckServiceEnvelope: %v", err) - } - - // 设备侧解密 - env, err := parseServiceEnvelope(raw) - if err != nil { - t.Fatalf("parse: %v", err) - } - if env.ChannelID != PKIChannelID { - t.Fatalf("channel_id = %q", env.ChannelID) - } - pkt := env.Packet - if !pkt.PKIEncrypted || pkt.From != fromNum || pkt.To != toNum || pkt.ID != ackPacketID { - t.Fatalf("ack header mismatch: %+v", pkt) - } - encryptedLen := len(pkt.Encrypted) - pkcOverhead - cipher := pkt.Encrypted[:encryptedLen] - auth := pkt.Encrypted[encryptedLen : encryptedLen+8] - extraNonce := binary.LittleEndian.Uint32(pkt.Encrypted[encryptedLen+8:]) - sharedKey, err := pkiSharedKey(devicePriv.Bytes(), botPriv.PublicKey().Bytes()) - if err != nil { - t.Fatalf("shared: %v", err) - } - plain, err := aesCCMDecrypt(sharedKey, pkiNonce(ackPacketID, fromNum, extraNonce), cipher, auth) - if err != nil { - t.Fatalf("decrypt: %v", err) - } - data, err := parseDataPacket(plain) - if err != nil { - t.Fatalf("data: %v", err) - } - if data.Portnum != routingApp { - t.Fatalf("portnum = %d, want ROUTING_APP(%d)", data.Portnum, routingApp) - } - - // Routing payload 解析: 期望 oneof error_reason=NONE(0),即 wire 字节 0x18 0x00 - wantRouting := []byte{0x18, 0x00} - if !bytes.Equal(data.Payload, wantRouting) { - t.Fatalf("routing payload = % x, want % x", data.Payload, wantRouting) - } -} diff --git a/internal/runtimesettings/runtime_settings_cache_test.go b/internal/runtimesettings/runtime_settings_cache_test.go deleted file mode 100644 index 5b85d09..0000000 --- a/internal/runtimesettings/runtime_settings_cache_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package runtimesettings - -import ( - "testing" - - storepkg "meshtastic_mqtt_server/internal/store" - "meshtastic_mqtt_server/internal/store/testutil" -) - -func openTestStore(t *testing.T) *storepkg.Store { - return testutil.OpenStore(t) -} - -func TestRuntimeSettingsCacheReload(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - cache, err := New(st) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if cache.AllowEncryptedForwarding() { - t.Fatalf("AllowEncryptedForwarding() = true, want false") - } - - if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { - t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err) - } - if err := cache.Reload(st); err != nil { - t.Fatalf("Reload() after true error = %v", err) - } - if !cache.AllowEncryptedForwarding() { - t.Fatalf("AllowEncryptedForwarding() = false, want true") - } - - if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { - t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err) - } - if err := cache.Reload(st); err != nil { - t.Fatalf("Reload() after false error = %v", err) - } - if cache.AllowEncryptedForwarding() { - t.Fatalf("AllowEncryptedForwarding() = true, want false") - } -} diff --git a/internal/store/blocking_store_test.go b/internal/store/blocking_store_test.go deleted file mode 100644 index 1523888..0000000 --- a/internal/store/blocking_store_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package store - -import ( - "errors" - "testing" - - "gorm.io/gorm" -) - -func TestNodeBlockingCRUD(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - nodeNum := int64(305419896) - rule, err := st.CreateNodeBlocking(" !12345678 ", &nodeNum, " noisy node ", true) - if err != nil { - t.Fatalf("CreateNodeBlocking() error = %v", err) - } - if rule.NodeID != "!12345678" || rule.NodeNum == nil || *rule.NodeNum != nodeNum || rule.Reason != "noisy node" || !rule.Enabled { - t.Fatalf("created node rule = %+v, want normalized fields", rule) - } - - if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { - t.Fatalf("duplicate CreateNodeBlocking() error = %v, want ErrBlockingAlreadyExists", err) - } - - updatedNum := int64(7) - updated, err := st.UpdateNodeBlocking(rule.ID, "!00000007", &updatedNum, "updated", false) - if err != nil { - t.Fatalf("UpdateNodeBlocking() error = %v", err) - } - if updated.NodeID != "!00000007" || updated.NodeNum == nil || *updated.NodeNum != updatedNum || updated.Reason != "updated" || updated.Enabled { - t.Fatalf("updated node rule = %+v, want updated fields", updated) - } - - rows, err := st.ListNodeBlocking(ListOptions{}) - if err != nil { - t.Fatalf("ListNodeBlocking() error = %v", err) - } - if len(rows) != 1 || rows[0].ID != rule.ID { - t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows) - } - total, err := st.CountNodeBlocking(ListOptions{}) - if err != nil || total != 1 { - t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err) - } - - if err := st.DeleteNodeBlocking(rule.ID); err != nil { - t.Fatalf("DeleteNodeBlocking() error = %v", err) - } - if err := st.DeleteNodeBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("DeleteNodeBlocking(missing) error = %v, want record not found", err) - } -} - -func TestNodeBlockingValidation(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateNodeBlocking(" ", nil, "", true); err == nil { - t.Fatal("CreateNodeBlocking(empty) error = nil, want error") - } - if _, err := st.UpdateNodeBlocking(1, "!missing", nil, "", true); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("UpdateNodeBlocking(missing) error = %v, want record not found", err) - } -} - -func TestIPBlockingCRUDAndValidation(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - rule, err := st.CreateIPBlocking(" 127.0.0.1 ", "local", true) - if err != nil { - t.Fatalf("CreateIPBlocking(ip) error = %v", err) - } - if rule.IPValue != "127.0.0.1" || rule.Reason != "local" || !rule.Enabled { - t.Fatalf("created ip rule = %+v, want normalized IP", rule) - } - - cidr, err := st.CreateIPBlocking("192.168.1.99/24", "cidr", true) - if err != nil { - t.Fatalf("CreateIPBlocking(cidr) error = %v", err) - } - if cidr.IPValue != "192.168.1.0/24" { - t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue) - } - - if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { - t.Fatalf("duplicate CreateIPBlocking() error = %v, want ErrBlockingAlreadyExists", err) - } - if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil { - t.Fatal("CreateIPBlocking(invalid) error = nil, want error") - } - - updated, err := st.UpdateIPBlocking(rule.ID, "10.0.0.0/8", "updated", false) - if err != nil { - t.Fatalf("UpdateIPBlocking() error = %v", err) - } - if updated.IPValue != "10.0.0.0/8" || updated.Reason != "updated" || updated.Enabled { - t.Fatalf("updated ip rule = %+v, want updated fields", updated) - } - - rows, err := st.ListIPBlocking(ListOptions{}) - if err != nil { - t.Fatalf("ListIPBlocking() error = %v", err) - } - if len(rows) != 2 { - t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows)) - } - total, err := st.CountIPBlocking(ListOptions{}) - if err != nil || total != 2 { - t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err) - } - - if err := st.DeleteIPBlocking(rule.ID); err != nil { - t.Fatalf("DeleteIPBlocking() error = %v", err) - } - if err := st.DeleteIPBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("DeleteIPBlocking(missing) error = %v, want record not found", err) - } -} - -func TestListEnabledBlockingRules(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - nodeNum := int64(1) - if _, err := st.CreateNodeBlocking("!00000001", &nodeNum, "enabled", true); err != nil { - t.Fatalf("CreateNodeBlocking(enabled) error = %v", err) - } - if _, err := st.CreateNodeBlocking("!00000002", nil, "disabled", false); err != nil { - t.Fatalf("CreateNodeBlocking(disabled) error = %v", err) - } - if rows, err := st.ListEnabledNodeBlocking(); err != nil || len(rows) != 1 || rows[0].NodeID != "!00000001" { - t.Fatalf("ListEnabledNodeBlocking() = %+v, %v, want only enabled node", rows, err) - } - - if _, err := st.CreateIPBlocking("127.0.0.1", "enabled", true); err != nil { - t.Fatalf("CreateIPBlocking(enabled) error = %v", err) - } - if _, err := st.CreateIPBlocking("192.168.1.1", "disabled", false); err != nil { - t.Fatalf("CreateIPBlocking(disabled) error = %v", err) - } - if rows, err := st.ListEnabledIPBlocking(); err != nil || len(rows) != 1 || rows[0].IPValue != "127.0.0.1" { - t.Fatalf("ListEnabledIPBlocking() = %+v, %v, want only enabled IP", rows, err) - } - - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "enabled", true); err != nil { - t.Fatalf("CreateForbiddenWordBlocking(enabled) error = %v", err) - } - if _, err := st.CreateForbiddenWordBlocking("eggs", "contains", false, "disabled", false); err != nil { - t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err) - } - if rows, err := st.ListEnabledForbiddenWordBlocking(); err != nil || len(rows) != 1 || rows[0].Word != "spam" { - t.Fatalf("ListEnabledForbiddenWordBlocking() = %+v, %v, want only enabled word", rows, err) - } -} - -func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - rule, err := st.CreateForbiddenWordBlocking(" spam ", "", false, "junk", true) - if err != nil { - t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) - } - if rule.Word != "spam" || rule.MatchType != ForbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled { - t.Fatalf("created word rule = %+v, want normalized fields", rule) - } - - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { - t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want ErrBlockingAlreadyExists", err) - } - if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil { - t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error") - } - if _, err := st.CreateForbiddenWordBlocking("regex", "regex", false, "unsupported", true); err == nil { - t.Fatal("CreateForbiddenWordBlocking(unsupported match type) error = nil, want error") - } - - updated, err := st.UpdateForbiddenWordBlocking(rule.ID, "Spam", "contains", true, "updated", false) - if err != nil { - t.Fatalf("UpdateForbiddenWordBlocking() error = %v", err) - } - if updated.Word != "Spam" || updated.MatchType != "contains" || !updated.CaseSensitive || updated.Reason != "updated" || updated.Enabled { - t.Fatalf("updated word rule = %+v, want updated fields", updated) - } - - rows, err := st.ListForbiddenWordBlocking(ListOptions{}) - if err != nil { - t.Fatalf("ListForbiddenWordBlocking() error = %v", err) - } - if len(rows) != 1 || rows[0].ID != rule.ID { - t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows) - } - total, err := st.CountForbiddenWordBlocking(ListOptions{}) - if err != nil || total != 1 { - t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err) - } - - if err := st.DeleteForbiddenWordBlocking(rule.ID); err != nil { - t.Fatalf("DeleteForbiddenWordBlocking() error = %v", err) - } - if err := st.DeleteForbiddenWordBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("DeleteForbiddenWordBlocking(missing) error = %v, want record not found", err) - } -} diff --git a/internal/store/db_test.go b/internal/store/db_test.go deleted file mode 100644 index 75ddde7..0000000 --- a/internal/store/db_test.go +++ /dev/null @@ -1,1108 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/base64" - "errors" - "path/filepath" - "strings" - "testing" - "time" - - "gorm.io/gorm" - - "meshtastic_mqtt_server/internal/config" -) - -func TestOpenStoreCreatesTables(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - for _, table := range []string{"users", "login_log", "runtime_settings", "map_tile_sources", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { - var name string - if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { - t.Fatalf("%s table missing: %v", table, err) - } - if name != table { - t.Fatalf("table name = %q, want %s", name, table) - } - } - - var oldCount int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodeinfo_map'").Scan(&oldCount); err != nil { - t.Fatal(err) - } - if oldCount != 0 { - t.Fatalf("nodeinfo_map table count = %d, want 0", oldCount) - } -} - -func TestCountSignsByDayFormatsDateString(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateSign("!11111111", nil, nil, "first", time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC)); err != nil { - t.Fatalf("CreateSign() error = %v", err) - } - if _, err := st.CreateSign("!22222222", nil, nil, "second", time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)); err != nil { - t.Fatalf("CreateSign() error = %v", err) - } - if _, err := st.CreateSign("!33333333", nil, nil, "third", time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC)); err != nil { - t.Fatalf("CreateSign() error = %v", err) - } - - rows, err := st.CountSignsByDay(ListOptions{}) - if err != nil { - t.Fatalf("CountSignsByDay() error = %v", err) - } - if len(rows) != 2 { - t.Fatalf("CountSignsByDay() length = %d, want 2", len(rows)) - } - if rows[0].Date != "2026-06-16" || rows[0].Count != 1 { - t.Fatalf("first day count = %#v, want 2026-06-16 count 1", rows[0]) - } - if rows[1].Date != "2026-06-15" || rows[1].Count != 2 { - t.Fatalf("second day count = %#v, want 2026-06-15 count 2", rows[1]) - } -} - -func TestUpsertNodeInfoInsertsAndUpdatesSameNode(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - first := nodeInfoTestRecord("first name") - if err := st.UpsertNodeInfo(first); err != nil { - t.Fatalf("first UpsertNodeInfo() error = %v", err) - } - - second := nodeInfoTestRecord("second name") - second["short_name"] = "snd" - if err := st.UpsertNodeInfo(second); err != nil { - t.Fatalf("second UpsertNodeInfo() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("nodeinfo row count = %d, want 1", count) - } - - var longName, shortName, content string - if err := rawTestDB(t, st).QueryRow("SELECT long_name, short_name, content_json FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&longName, &shortName, &content); err != nil { - t.Fatal(err) - } - if longName != "second name" || shortName != "snd" { - t.Fatalf("nodeinfo names = %q/%q, want second name/snd", longName, shortName) - } - if !strings.Contains(content, "second name") { - t.Fatalf("content_json = %q, want updated content", content) - } -} - -func TestUpsertMapReportInsertsAndUpdatesSameNode(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - first := mapReportTestRecord("first map") - if err := st.UpsertMapReport(first); err != nil { - t.Fatalf("first UpsertMapReport() error = %v", err) - } - - second := mapReportTestRecord("second map") - second["latitude"] = 43.5 - if err := st.UpsertMapReport(second); err != nil { - t.Fatalf("second UpsertMapReport() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("map_report row count = %d, want 1", count) - } - - var longName string - var latitude float64 - var opted sql.NullBool - if err := rawTestDB(t, st).QueryRow("SELECT long_name, latitude, has_opted_report_location FROM map_report WHERE node_id = ?", "!12345678").Scan(&longName, &latitude, &opted); err != nil { - t.Fatal(err) - } - if longName != "second map" || latitude != 43.5 { - t.Fatalf("map_report row = %q/%v, want second map/43.5", longName, latitude) - } - if !opted.Valid || opted.Bool { - t.Fatalf("has_opted_report_location = %+v, want valid false", opted) - } -} - -func TestListMapReportsFiltersByBounds(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - inside := mapReportTestRecord("inside") - inside["from"] = "!00000001" - inside["from_num"] = uint32(1) - inside["latitude"] = 10.5 - inside["longitude"] = 20.5 - outside := mapReportTestRecord("outside") - outside["from"] = "!00000002" - outside["from_num"] = uint32(2) - outside["latitude"] = 50.0 - outside["longitude"] = 20.5 - missingCoords := mapReportTestRecord("missing coords") - missingCoords["from"] = "!00000003" - missingCoords["from_num"] = uint32(3) - delete(missingCoords, "latitude") - delete(missingCoords, "longitude") - - for _, record := range []map[string]any{inside, outside, missingCoords} { - if err := st.UpsertMapReport(record); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - } - - minLat, maxLat := 10.0, 11.0 - minLng, maxLng := 20.0, 21.0 - opts := ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng} - rows, err := st.ListMapReports(opts) - if err != nil { - t.Fatalf("ListMapReports() error = %v", err) - } - if len(rows) != 1 || rows[0].NodeID != "!00000001" { - t.Fatalf("ListMapReports() = %+v, want only !00000001", rows) - } - total, err := st.CountMapReports(opts) - if err != nil || total != 1 { - t.Fatalf("CountMapReports() = %d, %v, want 1, nil", total, err) - } -} - -func TestListMapReportsFiltersAcrossAntimeridian(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - west := mapReportTestRecord("west") - west["from"] = "!00000001" - west["from_num"] = uint32(1) - west["latitude"] = 0.0 - west["longitude"] = 175.0 - east := mapReportTestRecord("east") - east["from"] = "!00000002" - east["from_num"] = uint32(2) - east["latitude"] = 0.0 - east["longitude"] = -175.0 - outside := mapReportTestRecord("outside") - outside["from"] = "!00000003" - outside["from_num"] = uint32(3) - outside["latitude"] = 0.0 - outside["longitude"] = 0.0 - - for _, record := range []map[string]any{west, east, outside} { - if err := st.UpsertMapReport(record); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - } - - minLat, maxLat := -10.0, 10.0 - minLng, maxLng := 170.0, -170.0 - rows, err := st.ListMapReports(ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}) - if err != nil { - t.Fatalf("ListMapReports() error = %v", err) - } - if len(rows) != 2 { - t.Fatalf("ListMapReports() length = %d, want 2: %+v", len(rows), rows) - } - seen := map[string]bool{} - for _, row := range rows { - seen[row.NodeID] = true - } - if !seen["!00000001"] || !seen["!00000002"] || seen["!00000003"] { - t.Fatalf("seen nodes = %+v, want west/east only", seen) - } -} - -func TestListMapReportViewportReturnsPointsBelowThreshold(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - for index := 0; index < 3; index++ { - record := mapReportTestRecord("point") - record["from"] = "!0000000" + string(rune('1'+index)) - record["from_num"] = uint32(index + 1) - record["latitude"] = float64(index) - record["longitude"] = float64(index) - if err := st.UpsertMapReport(record); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - } - - minLat, maxLat := -1.0, 5.0 - minLng, maxLng := -1.0, 5.0 - result, err := st.ListMapReportViewport(MapReportViewportOptions{ - ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, - Zoom: 8, - Limit: 1000, - ClusterThreshold: 10, - TargetCells: 64, - }) - if err != nil { - t.Fatalf("ListMapReportViewport() error = %v", err) - } - if result.Mode != "points" || result.Total != 3 || len(result.Points) != 3 || len(result.Clusters) != 0 { - t.Fatalf("viewport result = %+v, want 3 points", result) - } -} - -func TestListMapReportViewportReturnsClustersAboveThreshold(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - for index := 0; index < 4; index++ { - record := mapReportTestRecord("cluster") - record["from"] = "!0000000" + string(rune('1'+index)) - record["from_num"] = uint32(index + 1) - record["latitude"] = 10.0 + float64(index)*0.01 - record["longitude"] = 20.0 + float64(index)*0.01 - if err := st.UpsertMapReport(record); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - } - - minLat, maxLat := 9.0, 11.0 - minLng, maxLng := 19.0, 21.0 - result, err := st.ListMapReportViewport(MapReportViewportOptions{ - ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, - Zoom: 4, - Limit: 1000, - ClusterThreshold: 2, - TargetCells: 1, - }) - if err != nil { - t.Fatalf("ListMapReportViewport() error = %v", err) - } - if result.Mode != "clusters" || result.Total != 4 || len(result.Clusters) != 1 || result.Clusters[0].Count != 4 { - t.Fatalf("viewport result = %+v, want one cluster with count 4", result) - } - if result.Clusters[0].Latitude < 10 || result.Clusters[0].Latitude > 10.1 || result.Clusters[0].Longitude < 20 || result.Clusters[0].Longitude > 20.1 { - t.Fatalf("cluster center = %v/%v, want center near inserted points", result.Clusters[0].Latitude, result.Clusters[0].Longitude) - } -} - -func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - - var nodeLongName, userID, publicKey string - if err := rawTestDB(t, st).QueryRow("SELECT long_name, user_id, public_key FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&nodeLongName, &userID, &publicKey); err != nil { - t.Fatal(err) - } - if nodeLongName != "map name" || userID != "!12345678" || publicKey != "abcd" { - t.Fatalf("nodeinfo row = %q/%q/%q, want synced map name plus node-only fields", nodeLongName, userID, publicKey) - } - - var mapLongName, firmware string - var latitude float64 - if err := rawTestDB(t, st).QueryRow("SELECT long_name, firmware_version, latitude FROM map_report WHERE node_id = ?", "!12345678").Scan(&mapLongName, &firmware, &latitude); err != nil { - t.Fatal(err) - } - if mapLongName != "map name" || firmware != "1.2.3" || latitude != 42.5 { - t.Fatalf("map_report row = %q/%q/%v, want map fields", mapLongName, firmware, latitude) - } -} - -func TestUpsertNodeInfoUpdatesExistingMapReportFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - node := nodeInfoTestRecord("node name") - node["short_name"] = "nod" - node["hw_model"] = "NODE_HW" - node["role"] = "CLIENT" - if err := st.UpsertNodeInfo(node); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - - var longName, shortName, hwModel, role, firmware string - var latitude float64 - if err := rawTestDB(t, st).QueryRow("SELECT long_name, short_name, hw_model, role, firmware_version, latitude FROM map_report WHERE node_id = ?", "!12345678").Scan(&longName, &shortName, &hwModel, &role, &firmware, &latitude); err != nil { - t.Fatal(err) - } - if longName != "node name" || shortName != "nod" || hwModel != "NODE_HW" || role != "CLIENT" || firmware != "1.2.3" || latitude != 42.5 { - t.Fatalf("map_report row = %q/%q/%q/%q firmware %q lat %v, want node fields plus existing map fields", longName, shortName, hwModel, role, firmware, latitude) - } -} - -func TestUpsertNodeInfoDoesNotCreateMapReport(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("map_report count = %d, want 0", count) - } -} - -func TestUpsertMapReportUpdatesExistingNodeInfoFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - report := mapReportTestRecord("map name") - report["short_name"] = "map" - report["hw_model"] = "MAP_HW" - report["role"] = "CLIENT_MUTE" - if err := st.UpsertMapReport(report); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - - var longName, shortName, hwModel, role, userID, publicKey string - if err := rawTestDB(t, st).QueryRow("SELECT long_name, short_name, hw_model, role, user_id, public_key FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&longName, &shortName, &hwModel, &role, &userID, &publicKey); err != nil { - t.Fatal(err) - } - if longName != "map name" || shortName != "map" || hwModel != "MAP_HW" || role != "CLIENT_MUTE" || userID != "!12345678" || publicKey != "abcd" { - t.Fatalf("nodeinfo row = %q/%q/%q/%q user %q key %q, want map fields plus existing node-only fields", longName, shortName, hwModel, role, userID, publicKey) - } -} - -func TestUpsertMapReportDoesNotCreateNodeInfo(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("nodeinfo count = %d, want 0", count) - } -} - -func TestDeleteNodeDeletesNodeInfoAndMapReport(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - if err := st.DeleteNode("!12345678"); err != nil { - t.Fatalf("DeleteNode() error = %v", err) - } - - var nodeCount, reportCount int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&nodeCount); err != nil { - t.Fatal(err) - } - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&reportCount); err != nil { - t.Fatal(err) - } - if nodeCount != 0 || reportCount != 0 { - t.Fatalf("nodeinfo/map_report counts = %d/%d, want 0/0", nodeCount, reportCount) - } - if err := st.DeleteNode("!12345678"); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("DeleteNode(missing) error = %v, want record not found", err) - } -} - -func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertNodeInfo(map[string]any{"type": "nodeinfo", "from_num": 1}); err == nil || !strings.Contains(err.Error(), "from") { - t.Fatalf("missing from error = %v, want from error", err) - } - if err := st.UpsertNodeInfo(map[string]any{"type": "nodeinfo", "from": "!00000001"}); err == nil || !strings.Contains(err.Error(), "from_num") { - t.Fatalf("missing from_num error = %v, want from_num error", err) - } -} - -func TestUpsertMapReportRequiresNodeFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertMapReport(map[string]any{"type": "map_report", "from_num": 1}); err == nil || !strings.Contains(err.Error(), "from") { - t.Fatalf("missing from error = %v, want from error", err) - } - if err := st.UpsertMapReport(map[string]any{"type": "map_report", "from": "!00000001"}); err == nil || !strings.Contains(err.Error(), "from_num") { - t.Fatalf("missing from_num error = %v, want from_num error", err) - } -} - -func TestNodeInfoFromRecordRejectsWrongType(t *testing.T) { - _, err := nodeInfoFromRecord(map[string]any{"type": "map_report"}) - if err == nil { - t.Fatalf("nodeInfoFromRecord() error = nil, want error") - } -} - -func TestMapReportFromRecordRejectsWrongType(t *testing.T) { - _, err := mapReportFromRecord(map[string]any{"type": "nodeinfo"}) - if err == nil { - t.Fatalf("mapReportFromRecord() error = nil, want error") - } -} - -func TestNodeInfoNullablePublicKey(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - record := map[string]any{"type": "nodeinfo", "from": "!00000001", "from_num": 1, "public_key": nil} - if err := st.UpsertNodeInfo(record); err != nil { - t.Fatalf("UpsertNodeInfo() error = %v", err) - } - - var publicKey sql.NullString - if err := rawTestDB(t, st).QueryRow("SELECT public_key FROM nodeinfo WHERE node_id = ?", "!00000001").Scan(&publicKey); err != nil { - t.Fatal(err) - } - if publicKey.Valid { - t.Fatalf("public_key valid = true, want null") - } -} - -func TestEnsureDefaultAdminCreatesAdminUser(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.EnsureDefaultAdmin("admin", "admin"); err != nil { - t.Fatalf("EnsureDefaultAdmin() error = %v", err) - } - - user, err := st.GetUserByUsername("admin") - if err != nil { - t.Fatalf("GetUserByUsername() error = %v", err) - } - if user.Role != AdminRole { - t.Fatalf("role = %q, want admin", user.Role) - } - if user.PasswordHash == "admin" || user.PasswordHash == "" { - t.Fatalf("password hash = %q, want bcrypt hash", user.PasswordHash) - } - if !verifyPassword(user.PasswordHash, "admin") { - t.Fatalf("admin password did not verify") - } -} - -func TestEnsureDefaultAdminDoesNotOverwriteExistingUser(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.EnsureDefaultAdmin("admin", "first"); err != nil { - t.Fatalf("first EnsureDefaultAdmin() error = %v", err) - } - if err := st.EnsureDefaultAdmin("admin", "second"); err != nil { - t.Fatalf("second EnsureDefaultAdmin() error = %v", err) - } - user, err := st.GetUserByUsername("admin") - if err != nil { - t.Fatalf("GetUserByUsername() error = %v", err) - } - if !verifyPassword(user.PasswordHash, "first") || verifyPassword(user.PasswordHash, "second") { - t.Fatalf("admin password was overwritten") - } -} - -func TestCreateAdminUserCreatesHashedAdmin(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - user, err := st.CreateAdminUser("new-admin", "secret") - if err != nil { - t.Fatalf("CreateAdminUser() error = %v", err) - } - if user.Username != "new-admin" || user.Role != AdminRole { - t.Fatalf("user = %#v, want new-admin admin", user) - } - if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") { - t.Fatalf("password hash did not verify") - } -} - -func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil { - t.Fatalf("first CreateAdminUser() error = %v", err) - } - if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, ErrUserAlreadyExists) { - t.Fatalf("duplicate CreateAdminUser() error = %v, want ErrUserAlreadyExists", err) - } -} - -func TestUpdateUserPasswordChangesHash(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - user, err := st.CreateAdminUser("new-admin", "old-secret") - if err != nil { - t.Fatalf("CreateAdminUser() error = %v", err) - } - oldHash := user.PasswordHash - updated, err := st.UpdateUserPassword(user.ID, "new-secret") - if err != nil { - t.Fatalf("UpdateUserPassword() error = %v", err) - } - if updated.PasswordHash == oldHash { - t.Fatalf("password hash did not change") - } - if verifyPassword(updated.PasswordHash, "old-secret") || !verifyPassword(updated.PasswordHash, "new-secret") { - t.Fatalf("updated password verification mismatch") - } -} - -func TestUpdateUserPasswordMissingUser(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.UpdateUserPassword(999, "new-secret"); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("UpdateUserPassword() error = %v, want record not found", err) - } -} - -func TestInsertAndListLoginLogs(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - userID := uint64(1) - if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { - t.Fatalf("InsertLoginLog(success) error = %v", err) - } - if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { - t.Fatalf("InsertLoginLog(failure) error = %v", err) - } - - logs, err := st.ListLoginLogs(ListOptions{Limit: 10}) - if err != nil { - t.Fatalf("ListLoginLogs() error = %v", err) - } - if len(logs) != 2 { - t.Fatalf("login logs len = %d, want 2", len(logs)) - } - if logs[0].ID <= logs[1].ID { - t.Fatalf("login logs not newest first: ids %d, %d", logs[0].ID, logs[1].ID) - } - if logs[0].Success || logs[0].Reason != "invalid username or password" { - t.Fatalf("latest log = %#v, want failure", logs[0]) - } - if logs[1].UserID == nil || *logs[1].UserID != userID || !logs[1].Success { - t.Fatalf("success log = %#v, want user id and success", logs[1]) - } -} - -func TestInsertDiscardDetailsStoresRawBase64AndClientInfo(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - raw := []byte{0xff, 0x00, 0x01} - clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} - record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)} - if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil { - t.Fatalf("InsertDiscardDetails() error = %v", err) - } - - var topic, errorText, rawBase64, clientID, username, listener, remoteAddr, remoteHost, remotePort, contentJSON string - var payloadLen int64 - if err := rawTestDB(t, st).QueryRow("SELECT topic, error, payload_len, raw_base64, mqtt_client_id, mqtt_username, mqtt_listener, mqtt_remote_addr, mqtt_remote_host, mqtt_remote_port, content_json FROM discard_details LIMIT 1").Scan(&topic, &errorText, &payloadLen, &rawBase64, &clientID, &username, &listener, &remoteAddr, &remoteHost, &remotePort, &contentJSON); err != nil { - t.Fatal(err) - } - if topic != "msh/US/test" || errorText != "protobuf decode failed" || payloadLen != int64(len(raw)) || rawBase64 != base64.StdEncoding.EncodeToString(raw) { - t.Fatalf("discard details row = topic %q error %q len %d raw %q", topic, errorText, payloadLen, rawBase64) - } - if clientID != "client-1" || username != "user-1" || listener != "tcp" || remoteAddr != "127.0.0.1:54321" || remoteHost != "127.0.0.1" || remotePort != "54321" { - t.Fatalf("client info = %q %q %q %q %q %q", clientID, username, listener, remoteAddr, remoteHost, remotePort) - } - if !strings.Contains(contentJSON, "protobuf decode failed") { - t.Fatalf("content_json = %q, want error", contentJSON) - } -} - -func TestListDiscardDetailsOrdersNewestFirst(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, MQTTClientInfo{}); err != nil { - t.Fatalf("first InsertDiscardDetails() error = %v", err) - } - if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, MQTTClientInfo{}); err != nil { - t.Fatalf("second InsertDiscardDetails() error = %v", err) - } - rows, err := st.ListDiscardDetails(ListOptions{Limit: 10}) - if err != nil { - t.Fatalf("ListDiscardDetails() error = %v", err) - } - if len(rows) != 2 { - t.Fatalf("discard details len = %d, want 2", len(rows)) - } - if rows[0].ID <= rows[1].ID || rows[0].Topic != "second" { - t.Fatalf("discard details order = %#v", rows) - } -} - -func TestInsertTextMessageAppendsRows(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} - if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil { - t.Fatalf("first InsertTextMessage() error = %v", err) - } - if err := st.InsertTextMessage(textMessageTestRecord("hello again"), clientInfo); err != nil { - t.Fatalf("second InsertTextMessage() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM text_message WHERE from_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("text_message count = %d, want 2", count) - } - - rows, err := rawTestDB(t, st).Query("SELECT id FROM text_message ORDER BY id") - if err != nil { - t.Fatal(err) - } - defer rows.Close() - var ids []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - t.Fatal(err) - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - if len(ids) != 2 || ids[0] <= 0 || ids[1] <= ids[0] { - t.Fatalf("ids = %v, want increasing positive ids", ids) - } -} - -func TestDeleteTextMessageDeletesRows(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertTextMessage(textMessageTestRecord("hello"), MQTTClientInfo{}); err != nil { - t.Fatalf("InsertTextMessage() error = %v", err) - } - var id uint64 - if err := rawTestDB(t, st).QueryRow("SELECT id FROM text_message LIMIT 1").Scan(&id); err != nil { - t.Fatal(err) - } - if err := st.DeleteTextMessage(id); err != nil { - t.Fatalf("DeleteTextMessage() error = %v", err) - } - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM text_message WHERE id = ?", id).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("text_message count = %d, want 0", count) - } - if err := st.DeleteTextMessage(id); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("DeleteTextMessage(missing) error = %v, want record not found", err) - } -} - -func TestInsertTextMessageStoresClientInfo(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} - if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil { - t.Fatalf("InsertTextMessage() error = %v", err) - } - - var clientID, username, listener, remoteAddr, remoteHost, remotePort string - if err := rawTestDB(t, st).QueryRow("SELECT mqtt_client_id, mqtt_username, mqtt_listener, mqtt_remote_addr, mqtt_remote_host, mqtt_remote_port FROM text_message LIMIT 1").Scan(&clientID, &username, &listener, &remoteAddr, &remoteHost, &remotePort); err != nil { - t.Fatal(err) - } - if clientID != "client-1" || username != "user-1" || listener != "tcp" || remoteAddr != "127.0.0.1:54321" || remoteHost != "127.0.0.1" || remotePort != "54321" { - t.Fatalf("client info = %q %q %q %q %q %q", clientID, username, listener, remoteAddr, remoteHost, remotePort) - } -} - -func TestInsertTextMessageStoresPayloadHex(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - record := textMessageTestRecord(nil) - record["payload_hex"] = "fffefd" - if err := st.InsertTextMessage(record, MQTTClientInfo{}); err != nil { - t.Fatalf("InsertTextMessage() error = %v", err) - } - - var text sql.NullString - var payloadHex string - if err := rawTestDB(t, st).QueryRow("SELECT text, payload_hex FROM text_message LIMIT 1").Scan(&text, &payloadHex); err != nil { - t.Fatal(err) - } - if text.Valid { - t.Fatalf("text valid = true, want null") - } - if payloadHex != "fffefd" { - t.Fatalf("payload_hex = %q, want fffefd", payloadHex) - } -} - -func TestInsertTextMessageRequiresFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertTextMessage(map[string]any{"type": "nodeinfo"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "text_message") { - t.Fatalf("wrong type error = %v, want text_message error", err) - } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from_num": 1, "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from") { - t.Fatalf("missing from error = %v, want from error", err) - } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from_num") { - t.Fatalf("missing from_num error = %v, want from_num error", err) - } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "from_num": 1}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "topic") { - t.Fatalf("missing topic error = %v, want topic error", err) - } -} - -func TestInsertPositionAppendsRows(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - clientInfo := MQTTClientInfo{ClientID: "client-1", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} - if err := st.InsertPosition(positionTestRecord(), clientInfo); err != nil { - t.Fatalf("first InsertPosition() error = %v", err) - } - if err := st.InsertPosition(positionTestRecord(), clientInfo); err != nil { - t.Fatalf("second InsertPosition() error = %v", err) - } - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM position WHERE from_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("position count = %d, want 2", count) - } - - var latitude, longitude float64 - var altitude int64 - var locationSource, remoteHost string - if err := rawTestDB(t, st).QueryRow("SELECT latitude, longitude, altitude, location_source, mqtt_remote_host FROM position ORDER BY id LIMIT 1").Scan(&latitude, &longitude, &altitude, &locationSource, &remoteHost); err != nil { - t.Fatal(err) - } - if latitude != 42.5 || longitude != -83.1 || altitude != 200 || locationSource != "LOC_INTERNAL" || remoteHost != "127.0.0.1" { - t.Fatalf("position row = lat %v lon %v alt %v source %q remote %q", latitude, longitude, altitude, locationSource, remoteHost) - } -} - -func TestInsertPositionCreatesMapReportWhenMissing(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertPosition(positionTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("InsertPosition() error = %v", err) - } - - var nodeID string - var nodeNum int64 - var latitude, longitude float64 - var altitude, precision int64 - if err := rawTestDB(t, st).QueryRow("SELECT node_id, node_num, latitude, longitude, altitude, position_precision FROM map_report WHERE node_id = ?", "!12345678").Scan(&nodeID, &nodeNum, &latitude, &longitude, &altitude, &precision); err != nil { - t.Fatal(err) - } - if nodeID != "!12345678" || nodeNum != 0x12345678 || latitude != 42.5 || longitude != -83.1 || altitude != 200 || precision != 16 { - t.Fatalf("map_report from position = %q/%d lat %v lon %v alt %v precision %v", nodeID, nodeNum, latitude, longitude, altitude, precision) - } -} - -func TestInsertPositionUpdatesExistingMapReportCoordinates(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { - t.Fatalf("UpsertMapReport() error = %v", err) - } - position := positionTestRecord() - position["latitude"] = 30.25 - position["longitude"] = 120.75 - position["altitude"] = int32(88) - position["precision_bits"] = uint32(10) - if err := st.InsertPosition(position, MQTTClientInfo{}); err != nil { - t.Fatalf("InsertPosition() error = %v", err) - } - - var longName string - var latitude, longitude float64 - var altitude, precision int64 - if err := rawTestDB(t, st).QueryRow("SELECT long_name, latitude, longitude, altitude, position_precision FROM map_report WHERE node_id = ?", "!12345678").Scan(&longName, &latitude, &longitude, &altitude, &precision); err != nil { - t.Fatal(err) - } - if longName != "map name" || latitude != 30.25 || longitude != 120.75 || altitude != 88 || precision != 10 { - t.Fatalf("map_report after position = %q lat %v lon %v alt %v precision %v", longName, latitude, longitude, altitude, precision) - } -} - -func TestInsertTelemetryAppendsRowsAndStoresMetricsJSON(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertTelemetry(telemetryTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("InsertTelemetry() error = %v", err) - } - - var telemetryType, metricsJSON, contentJSON string - if err := rawTestDB(t, st).QueryRow("SELECT telemetry_type, metrics_json, content_json FROM telemetry LIMIT 1").Scan(&telemetryType, &metricsJSON, &contentJSON); err != nil { - t.Fatal(err) - } - if telemetryType != "device_metrics" { - t.Fatalf("telemetry_type = %q, want device_metrics", telemetryType) - } - if !strings.Contains(metricsJSON, "battery_level") || !strings.Contains(metricsJSON, "voltage") { - t.Fatalf("metrics_json = %q, want battery_level and voltage", metricsJSON) - } - if !strings.Contains(contentJSON, "telemetry") { - t.Fatalf("content_json = %q, want telemetry", contentJSON) - } -} - -func TestInsertRoutingAndTracerouteAppendRows(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("first InsertRouting() error = %v", err) - } - if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("second InsertRouting() error = %v", err) - } - if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("first InsertTraceroute() error = %v", err) - } - if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil { - t.Fatalf("second InsertTraceroute() error = %v", err) - } - - for _, table := range []string{"routing", "traceroute"} { - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM "+table+" WHERE from_id = ?", "!12345678").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("%s count = %d, want 2", table, count) - } - - var packetID int64 - var contentJSON string - if err := rawTestDB(t, st).QueryRow("SELECT packet_id, content_json FROM "+table+" ORDER BY id LIMIT 1").Scan(&packetID, &contentJSON); err != nil { - t.Fatal(err) - } - if packetID != 42 || !strings.Contains(contentJSON, table) { - t.Fatalf("%s row packet_id=%d content_json=%q", table, packetID, contentJSON) - } - } -} - -func TestInsertPacketTablesRequireFields(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - tests := []struct { - name string - insert func(map[string]any) error - record map[string]any - }{ - {name: "position", insert: func(r map[string]any) error { return st.InsertPosition(r, MQTTClientInfo{}) }, record: positionTestRecord()}, - {name: "telemetry", insert: func(r map[string]any) error { return st.InsertTelemetry(r, MQTTClientInfo{}) }, record: telemetryTestRecord()}, - {name: "routing", insert: func(r map[string]any) error { return st.InsertRouting(r, MQTTClientInfo{}) }, record: routingTestRecord()}, - {name: "traceroute", insert: func(r map[string]any) error { return st.InsertTraceroute(r, MQTTClientInfo{}) }, record: tracerouteTestRecord()}, - } - - for _, tt := range tests { - wrongType := cloneRecord(tt.record) - wrongType["type"] = "text_message" - if err := tt.insert(wrongType); err == nil || !strings.Contains(err.Error(), tt.name) { - t.Fatalf("%s wrong type error = %v, want %s", tt.name, err, tt.name) - } - - missingFrom := cloneRecord(tt.record) - delete(missingFrom, "from") - if err := tt.insert(missingFrom); err == nil || !strings.Contains(err.Error(), "from") { - t.Fatalf("%s missing from error = %v, want from error", tt.name, err) - } - - missingFromNum := cloneRecord(tt.record) - delete(missingFromNum, "from_num") - if err := tt.insert(missingFromNum); err == nil || !strings.Contains(err.Error(), "from_num") { - t.Fatalf("%s missing from_num error = %v, want from_num error", tt.name, err) - } - - missingTopic := cloneRecord(tt.record) - delete(missingTopic, "topic") - if err := tt.insert(missingTopic); err == nil || !strings.Contains(err.Error(), "topic") { - t.Fatalf("%s missing topic error = %v, want topic error", tt.name, err) - } - } -} - -func openTestStore(t *testing.T) *Store { - t.Helper() - st, err := OpenStore(config.DatabaseConfig{ - Driver: config.DriverSQLite, - SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, - }, false) - if err != nil { - t.Fatalf("OpenStore() error = %v", err) - } - return st -} - -func rawTestDB(t *testing.T, st *Store) *sql.DB { - t.Helper() - db, err := st.db.DB() - if err != nil { - t.Fatalf("st.db.DB() error = %v", err) - } - return db -} - -func nodeInfoTestRecord(longName string) map[string]any { - return map[string]any{ - "type": "nodeinfo", - "from": "!12345678", - "from_num": uint32(0x12345678), - "user_id": "!12345678", - "long_name": longName, - "short_name": "nod", - "hw_model": "TEST_HW", - "role": "CLIENT", - "is_licensed": true, - "public_key": "abcd", - } -} - -func mapReportTestRecord(longName string) map[string]any { - return map[string]any{ - "type": "map_report", - "from": "!12345678", - "from_num": uint32(0x12345678), - "long_name": longName, - "short_name": "map", - "role": "CLIENT_MUTE", - "hw_model": "TEST_HW_2", - "firmware_version": "1.2.3", - "region": "US", - "modem_preset": "LONG_FAST", - "latitude": 42.5, - "longitude": -83.1, - "altitude": int32(200), - "position_precision": uint32(12), - "num_online_local_nodes": uint32(3), - "has_opted_report_location": false, - } -} - -func textMessageTestRecord(text any) map[string]any { - record := commonPacketTestRecord("text_message", "TEXT_MESSAGE_APP") - record["text"] = text - return record -} - -func positionTestRecord() map[string]any { - record := commonPacketTestRecord("position", "POSITION_APP") - record["latitude"] = 42.5 - record["longitude"] = -83.1 - record["altitude"] = int32(200) - record["time"] = uint32(123456) - record["location_source"] = "LOC_INTERNAL" - record["altitude_source"] = "ALT_INTERNAL" - record["timestamp"] = uint32(123456) - record["timestamp_millis_adjust"] = uint32(10) - record["altitude_hae"] = int32(210) - record["altitude_geoidal_separation"] = int32(20) - record["pdop"] = 1.1 - record["hdop"] = 1.2 - record["vdop"] = 1.3 - record["gps_accuracy"] = uint32(1000) - record["ground_speed"] = uint32(2) - record["ground_track"] = 180.5 - record["fix_quality"] = uint32(1) - record["fix_type"] = uint32(3) - record["sats_in_view"] = uint32(8) - record["sensor_id"] = uint32(1) - record["next_update"] = uint32(60) - record["seq_number"] = uint32(7) - record["precision_bits"] = uint32(16) - return record -} - -func telemetryTestRecord() map[string]any { - record := commonPacketTestRecord("telemetry", "TELEMETRY_APP") - record["time"] = uint32(123456) - record["telemetry_type"] = "device_metrics" - record["metrics"] = map[string]any{"battery_level": 85, "voltage": 4.1} - return record -} - -func routingTestRecord() map[string]any { - return commonPacketTestRecord("routing", "ROUTING_APP") -} - -func tracerouteTestRecord() map[string]any { - return commonPacketTestRecord("traceroute", "TRACEROUTE_APP") -} - -func commonPacketTestRecord(recordType, portnum string) map[string]any { - return map[string]any{ - "type": recordType, - "topic": "msh/US/test", - "channel_id": "LongFast", - "gateway_id": "!gateway", - "from": "!12345678", - "from_num": uint32(0x12345678), - "packet_id": uint32(42), - "packet_to": "!ffffffff", - "packet_to_num": uint32(0xffffffff), - "portnum": portnum, - "payload_len": 5, - "payload_variant": "decoded", - "via_mqtt": true, - "pki_encrypted": false, - } -} - -func cloneRecord(record map[string]any) map[string]any { - clone := make(map[string]any, len(record)) - for key, value := range record { - clone[key] = value - } - return clone -} diff --git a/internal/store/db_write_queue_test.go b/internal/store/db_write_queue_test.go deleted file mode 100644 index ed01fcc..0000000 --- a/internal/store/db_write_queue_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package store - -import ( - "database/sql" - "testing" -) - -func TestDBWriteQueueWritesRecordsAsync(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - queue := newDBWriteQueue(st) - record := textMessageTestRecord("queued") - queue.EnqueueRecord(record, MQTTClientInfo{ClientID: "client-1"}) - record["text"] = "mutated after enqueue" - queue.Close() - - var text, clientID string - if err := rawTestDB(t, st).QueryRow("SELECT text, mqtt_client_id FROM text_message WHERE from_id = ?", "!12345678").Scan(&text, &clientID); err != nil { - t.Fatal(err) - } - if text != "queued" || clientID != "client-1" { - t.Fatalf("queued row = text %q client %q, want queued/client-1", text, clientID) - } -} - -func TestDBWriteQueueWritesDiscardAsync(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - queue := newDBWriteQueue(st) - record := map[string]any{"topic": "msh/test", "error": "bad packet"} - queue.EnqueueDiscard(record, []byte{1, 2, 3}, MQTTClientInfo{RemoteAddr: "127.0.0.1:1883"}) - record["error"] = "mutated after enqueue" - queue.Close() - - var topic, reason, rawBase64, remoteAddr string - if err := rawTestDB(t, st).QueryRow("SELECT topic, error, raw_base64, mqtt_remote_addr FROM discard_details").Scan(&topic, &reason, &rawBase64, &remoteAddr); err != nil { - t.Fatal(err) - } - if topic != "msh/test" || reason != "bad packet" || rawBase64 != "AQID" || remoteAddr != "127.0.0.1:1883" { - t.Fatalf("discard row = %q/%q/%q/%q, want queued values", topic, reason, rawBase64, remoteAddr) - } -} - -func TestDBWriteQueueLen(t *testing.T) { - queue := &WriteQueue{jobs: make(chan writeJob, 1)} - queue.enqueue(writeJob{run: func() error { return nil }}) - if queue.Len() != 1 { - t.Fatalf("queue.Len() = %d, want 1", queue.Len()) - } -} - -func TestDBWriteQueueIgnoresUnsupportedRecordType(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - queue := newDBWriteQueue(st) - queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, MQTTClientInfo{}) - queue.Close() - - var count int - if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM text_message").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("text_message count = %d, want 0", count) - } -} - -func TestDBWriteQueueNilStore(t *testing.T) { - if queue := newDBWriteQueue(nil); queue != nil { - t.Fatalf("newDBWriteQueue(nil) = %#v, want nil", queue) - } - var queue *WriteQueue - queue.EnqueueRecord(textMessageTestRecord("ignored"), MQTTClientInfo{}) - queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, MQTTClientInfo{}) - queue.Close() -} - -func TestDBWriteQueueRecordValidationErrorDoesNotStopWorker(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - queue := newDBWriteQueue(st) - badRecord := textMessageTestRecord("bad") - delete(badRecord, "from") - queue.EnqueueRecord(badRecord, MQTTClientInfo{}) - queue.EnqueueRecord(textMessageTestRecord("good"), MQTTClientInfo{}) - queue.Close() - - var text string - if err := rawTestDB(t, st).QueryRow("SELECT text FROM text_message").Scan(&text); err != nil { - t.Fatal(err) - } - if text != "good" { - t.Fatalf("text = %q, want good", text) - } - - var missing sql.NullString - if err := rawTestDB(t, st).QueryRow("SELECT text FROM text_message WHERE text = ?", "bad").Scan(&missing); err != sql.ErrNoRows { - t.Fatalf("bad row error = %v, want sql.ErrNoRows", err) - } -} diff --git a/internal/store/map_source_store_test.go b/internal/store/map_source_store_test.go deleted file mode 100644 index 17c9218..0000000 --- a/internal/store/map_source_store_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package store - -import ( - "errors" - "strings" - "testing" - - "gorm.io/gorm" -) - -func TestMapTileSourceDefaultSeeded(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.GetDefaultMapTileSource() - if err != nil { - t.Fatalf("GetDefaultMapTileSource() error = %v", err) - } - if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault { - t.Fatalf("default map source = %+v, want built-in default", row) - } -} - -func TestCreateMapTileSourceValidation(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { - t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error") - } - if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { - t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error") - } - if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { - t.Fatal("CreateMapTileSource() credentials error = nil, want error") - } -} - -func TestListEnabledMapTileSources(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - disabled, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource(disabled) error = %v", err) - } - custom, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource(custom) error = %v", err) - } - if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil { - t.Fatalf("SetDefaultMapTileSource() error = %v", err) - } - - rows, err := st.ListEnabledMapTileSources() - if err != nil { - t.Fatalf("ListEnabledMapTileSources() error = %v", err) - } - if len(rows) < 2 { - t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows)) - } - if rows[0].ID != custom.ID { - t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID) - } - for _, row := range rows { - if row.ID == disabled.ID { - t.Fatalf("disabled source was returned: %+v", row) - } - if !row.Enabled { - t.Fatalf("disabled row returned: %+v", row) - } - } -} - -func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - first, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) { - t.Fatalf("duplicate name error = %v, want ErrMapTileSourceAlreadyExists", err) - } - if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) { - t.Fatalf("duplicate url error = %v, want ErrMapTileSourceAlreadyExists", err) - } - - updated, err := st.SetDefaultMapTileSource(first.ID) - if err != nil { - t.Fatalf("SetDefaultMapTileSource() error = %v", err) - } - if !updated.IsDefault { - t.Fatalf("updated default = %+v, want is_default", updated) - } - - oldDefault, err := st.GetDefaultMapTileSource() - if err != nil { - t.Fatalf("GetDefaultMapTileSource() error = %v", err) - } - if oldDefault.ID != first.ID { - t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID) - } - if _, err := st.UpdateMapTileSource(first.ID, MapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, ErrMapTileSourceCannotDisableDefault) { - t.Fatalf("disable default error = %v, want ErrMapTileSourceCannotDisableDefault", err) - } - if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, ErrMapTileSourceCannotDeleteDefault) { - t.Fatalf("delete default error = %v, want ErrMapTileSourceCannotDeleteDefault", err) - } -} - -func TestMapTileSourceHashIsSetOnCreate(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - want := MapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png") - if row.URLTemplateHash != want { - t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want) - } - if !row.ProxyEnabled { - t.Fatal("ProxyEnabled = false, want true") - } -} - -func TestMapTileSourceDefaultHasHash(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.GetDefaultMapTileSource() - if err != nil { - t.Fatalf("GetDefaultMapTileSource() error = %v", err) - } - want := MapTileSourceHash(defaultMapTileSourceURLTemplate) - if row.URLTemplateHash != want { - t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want) - } -} - -func TestGetEnabledMapTileSourceByHash(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - found, err := st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) - if err != nil { - t.Fatalf("GetEnabledMapTileSourceByHash() error = %v", err) - } - if found.ID != row.ID { - t.Fatalf("found ID = %d, want %d", found.ID, row.ID) - } -} - -func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - _, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("GetEnabledMapTileSourceByHash(disabled) = %v, want gorm.ErrRecordNotFound", err) - } -} - -func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - _, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("GetEnabledMapTileSourceByHash(proxy disabled) = %v, want gorm.ErrRecordNotFound", err) - } -} - -func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - _, err := st.GetEnabledMapTileSourceByHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("GetEnabledMapTileSourceByHash(unknown) = %v, want gorm.ErrRecordNotFound", err) - } -} - -func TestPublicMapTileSourceDTOProxyURL(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - dto := publicMapTileSourceDTO(*row) - urlTemplate, ok := dto["url_template"].(string) - if !ok { - t.Fatal("url_template is not a string") - } - wantPrefix := "/api/map/" + row.URLTemplateHash + "?x={x}&y={y}&z={z}" - if urlTemplate != wantPrefix { - t.Fatalf("url_template = %q, want %q", urlTemplate, wantPrefix) - } - if strings.Contains(urlTemplate, "proxy.example.com") { - t.Fatal("url_template should not contain upstream hostname") - } -} - -func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - dto := publicMapTileSourceDTO(*row) - urlTemplate, ok := dto["url_template"].(string) - if !ok { - t.Fatal("url_template is not a string") - } - if urlTemplate != row.URLTemplate { - t.Fatalf("url_template = %q, want raw %q", urlTemplate, row.URLTemplate) - } -} - -func TestMapTileSourceHashFunction(t *testing.T) { - hash1 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") - hash2 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") - hash3 := MapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png") - - if hash1 != hash2 { - t.Fatal("hash should be deterministic") - } - if len(hash1) != 64 { - t.Fatalf("hash length = %d, want 64", len(hash1)) - } - if hash1 == hash3 { - t.Fatal("different URLs should produce different hashes") - } -} diff --git a/internal/store/runtime_settings_store_test.go b/internal/store/runtime_settings_store_test.go deleted file mode 100644 index 28b4155..0000000 --- a/internal/store/runtime_settings_store_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package store - -import "testing" - -func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - settings, err := st.GetRuntimeSettings() - if err != nil { - t.Fatalf("GetRuntimeSettings() error = %v", err) - } - if settings.AllowEncryptedForwarding { - t.Fatalf("AllowEncryptedForwarding = true, want false") - } - - if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { - t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err) - } - settings, err = st.GetRuntimeSettings() - if err != nil { - t.Fatalf("GetRuntimeSettings() after true error = %v", err) - } - if !settings.AllowEncryptedForwarding { - t.Fatalf("AllowEncryptedForwarding = false, want true") - } - - if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { - t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err) - } - settings, err = st.GetRuntimeSettings() - if err != nil { - t.Fatalf("GetRuntimeSettings() after false error = %v", err) - } - if settings.AllowEncryptedForwarding { - t.Fatalf("AllowEncryptedForwarding = true, want false") - } -} diff --git a/internal/store/test_helpers_test.go b/internal/store/test_helpers_test.go deleted file mode 100644 index 5d7492d..0000000 --- a/internal/store/test_helpers_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package store - -import ( - "strings" - - "golang.org/x/crypto/bcrypt" -) - -// 测试 helper —— 为从 main 包搬过来的测试提供它们原本依赖的小写函数。 -// 这些 helper 不暴露给生产代码使用;它们的行为应当与 main 包对应实现保持一致。 - -// verifyPassword 复刻 auth.go 中的 bcrypt 校验,用于 user_store 的测试。 -func verifyPassword(hash, password string) bool { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil -} - -// publicMapTileSourceDTO 复刻 admin_map_source_routes.go 中的同名函数, -// 仅供 map_source_store_test.go 验证 ProxyEnabled 时 URL 是否被改写。 -// 这里返回 map[string]any 而非 gin.H 以避免引入 gin 依赖。 -func publicMapTileSourceDTO(row MapTileSourceRecord) map[string]any { - urlTemplate := row.URLTemplate - if row.ProxyEnabled { - hash := row.URLTemplateHash - if hash == "" { - hash = MapTileSourceHash(row.URLTemplate) - } - urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" - } - return map[string]any{ - "id": row.ID, - "name": row.Name, - "url_template": urlTemplate, - "attribution": row.Attribution, - "max_zoom": row.MaxZoom, - "enabled": row.Enabled, - "is_default": row.IsDefault, - "proxy_enabled": row.ProxyEnabled, - } -} - -// newDBWriteQueue 是 db_write_queue_test.go 期望的旧名字。重新导出供测试使用。 -var newDBWriteQueue = NewWriteQueue - -// 让 strings 不会被 import-but-not-used(如果上面用不到,就算了——保留以应对将来扩展) -var _ = strings.TrimSpace diff --git a/internal/store/testutil/testutil.go b/internal/store/testutil/testutil.go deleted file mode 100644 index dcc3ed5..0000000 --- a/internal/store/testutil/testutil.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package testutil 提供给其它包测试使用的 store 临时实例工厂。 -// -// 重构前 db_test.go 中的 openTestStore helper 被 8+ 个测试文件复用; -// 现在抽到这里,让 store 包外的测试也可以零样板地拿到一个临时 SQLite store。 -package testutil - -import ( - "path/filepath" - "testing" - - "meshtastic_mqtt_server/internal/config" - "meshtastic_mqtt_server/internal/store" -) - -// OpenStore 返回一个写在 t.TempDir() 中的临时 SQLite store。 -// 测试结束时调用方需要 defer st.Close()。 -func OpenStore(t *testing.T) *store.Store { - t.Helper() - st, err := store.OpenStore(config.DatabaseConfig{ - Driver: config.DriverSQLite, - SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, - }, false) - if err != nil { - t.Fatalf("OpenStore() error = %v", err) - } - return st -} diff --git a/internal/web/map_tile_proxy_routes_test.go b/internal/web/map_tile_proxy_routes_test.go deleted file mode 100644 index 4941839..0000000 --- a/internal/web/map_tile_proxy_routes_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package web - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - configpkg "meshtastic_mqtt_server/internal/config" - storepkg "meshtastic_mqtt_server/internal/store" - "meshtastic_mqtt_server/internal/store/testutil" -) - -func openTestStore(t *testing.T) *storepkg.Store { - return testutil.OpenStore(t) -} - -func TestMapTileProxyFetchesAndCaches(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - requests := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - if r.URL.Path != "/3/1/2.png" { - t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path) - } - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write([]byte("tile-data")) - })) - defer upstream.Close() - - row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - cacheDir := t.TempDir() - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil, nil) - - url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3" - for i := 0; i < 2; i++ { - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, url, nil) - router.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String()) - } - if recorder.Body.String() != "tile-data" { - t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String()) - } - } - if requests != 1 { - t.Fatalf("upstream requests = %d, want 1", requests) - } - - cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile") - data, err := os.ReadFile(cachePath) - if err != nil { - t.Fatalf("read cache file %s: %v", cachePath, err) - } - if string(data) != "tile-data" { - t.Fatalf("cache file = %q, want tile-data", string(data)) - } -} - -func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) - } - - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) - - cases := []string{ - "/api/map/" + row.URLTemplateHash + "?y=0&z=0", - "/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0", - "/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4", - "/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1", - } - for _, url := range cases { - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, url, nil) - router.ServeHTTP(recorder, req) - if recorder.Code != http.StatusBadRequest { - t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String()) - } - } -} - -func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - disabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource(disabled) error = %v", err) - } - proxyDisabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false}) - if err != nil { - t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err) - } - - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) - - cases := []string{ - "/api/map/not-a-hash?x=0&y=0&z=0", - "/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0", - "/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0", - "/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0", - } - wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound} - for i, url := range cases { - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, url, nil) - router.ServeHTTP(recorder, req) - if recorder.Code != wantStatus[i] { - t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String()) - } - } -} - -func TestMapTileProxyUpstreamStatus(t *testing.T) { - st := openTestStore(t) - defer st.Close() - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/404/") { - http.NotFound(w, r) - return - } - http.Error(w, "upstream error", http.StatusInternalServerError) - })) - defer upstream.Close() - - row404, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource(404) error = %v", err) - } - row500, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) - if err != nil { - t.Fatalf("CreateMapTileSource(500) error = %v", err) - } - - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) - - cases := []struct { - url string - want int - }{ - {url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound}, - {url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway}, - } - for _, tc := range cases { - recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, tc.url, nil) - router.ServeHTTP(recorder, req) - if recorder.Code != tc.want { - t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String()) - } - } -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 544f329..0000000 --- a/main_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package main - -import ( - "testing" - - mqtt "github.com/mochi-mqtt/server/v2" - - blockingpkg "meshtastic_mqtt_server/internal/blocking" - storepkg "meshtastic_mqtt_server/internal/store" - "meshtastic_mqtt_server/internal/store/testutil" -) - -func TestMQTTClientInfoFromClientNil(t *testing.T) { - info := mqttClientInfoFromClient(nil) - if info != (storepkg.MQTTClientInfo{}) { - t.Fatalf("info = %#v, want zero value", info) - } -} - -func TestMQTTClientInfoFromClientIPv4(t *testing.T) { - info := mqttClientInfoFromClient(&mqtt.Client{ - ID: "client-1", - Properties: mqtt.ClientProperties{Username: []byte("user-1")}, - Net: mqtt.ClientConnection{Listener: "tcp", Remote: "127.0.0.1:1234"}, - }) - - if info.ClientID != "client-1" || info.Username != "user-1" || info.Listener != "tcp" { - t.Fatalf("client fields = %#v", info) - } - if info.RemoteAddr != "127.0.0.1:1234" || info.RemoteHost != "127.0.0.1" || info.RemotePort != "1234" { - t.Fatalf("remote fields = %#v", info) - } -} - -func TestMQTTClientInfoFromClientIPv6(t *testing.T) { - info := mqttClientInfoFromClient(&mqtt.Client{Net: mqtt.ClientConnection{Remote: "[::1]:1234"}}) - if info.RemoteHost != "::1" || info.RemotePort != "1234" { - t.Fatalf("remote fields = %#v, want host ::1 and port 1234", info) - } -} - -func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) { - info := mqttClientInfoFromClient(&mqtt.Client{Net: mqtt.ClientConnection{Remote: "localhost"}}) - if info.RemoteHost != "localhost" || info.RemotePort != "" { - t.Fatalf("remote fields = %#v, want host localhost and empty port", info) - } -} - -// blockingViolationForRecord 的测试用真实 *Store + blocking.Cache 走完整路径, -// 不依赖 cache 的未导出字段。 - -func TestBlockingViolationForRecordNode(t *testing.T) { - st := testutil.OpenStore(t) - defer st.Close() - nodeNum := int64(305419896) - if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "blocked", true); err != nil { - t.Fatalf("CreateNodeBlocking() error = %v", err) - } - cache, err := blockingpkg.New(st) - if err != nil { - t.Fatalf("blocking.New() error = %v", err) - } - record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)} - violation := blockingViolationForRecord(cache, record) - if violation == nil || violation["blocking_type"] != "node" { - t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation) - } -} - -func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { - st := testutil.OpenStore(t) - defer st.Close() - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { - t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) - } - cache, err := blockingpkg.New(st) - if err != nil { - t.Fatalf("blocking.New() error = %v", err) - } - - for _, tc := range []struct { - name string - record map[string]any - field string - }{ - {name: "text", record: map[string]any{"type": "text_message", "from": "!1", "text": "has SPAM"}, field: "text"}, - {name: "nodeinfo", record: map[string]any{"type": "nodeinfo", "from": "!1", "long_name": "has SPAM"}, field: "long_name"}, - {name: "map_report", record: map[string]any{"type": "map_report", "from": "!1", "long_name": "has SPAM"}, field: "long_name"}, - } { - t.Run(tc.name, func(t *testing.T) { - violation := blockingViolationForRecord(cache, tc.record) - if violation == nil || violation["blocking_type"] != "forbidden_word" || violation["blocking_field"] != tc.field || violation["matched_word"] != "spam" { - t.Fatalf("blockingViolationForRecord() = %#v, want forbidden word on %s", violation, tc.field) - } - }) - } -} - -func TestBlockingViolationForRecordAllowed(t *testing.T) { - st := testutil.OpenStore(t) - defer st.Close() - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { - t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) - } - cache, err := blockingpkg.New(st) - if err != nil { - t.Fatalf("blocking.New() error = %v", err) - } - record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"} - if violation := blockingViolationForRecord(cache, record); violation != nil { - t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation) - } -} diff --git a/tcp_nodelay_test.go b/tcp_nodelay_test.go deleted file mode 100644 index 12be317..0000000 --- a/tcp_nodelay_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "net" - "testing" - "time" - - mqtt "github.com/mochi-mqtt/server/v2" - "github.com/mochi-mqtt/server/v2/packets" -) - -// TestTCPNoDelay 测试 TCP_NODELAY 是否正确设置 -func TestTCPNoDelay(t *testing.T) { - // 创建一个模拟的 TCP 连接 - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to create listener: %v", err) - } - defer listener.Close() - - addr := listener.Addr().String() - - // 模拟客户端连接 - connChan := make(chan net.Conn, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - t.Errorf("Failed to accept connection: %v", err) - return - } - connChan <- conn - }() - - // 客户端连接 - clientConn, err := net.Dial("tcp", addr) - if err != nil { - t.Fatalf("Failed to dial: %v", err) - } - defer clientConn.Close() - - // 等待服务器端接受连接 - serverConn := <-connChan - defer serverConn.Close() - - // 创建 MQTT Client 包装 - cl := &mqtt.Client{ - Net: mqtt.ClientConnection{ - Conn: serverConn, - Remote: serverConn.RemoteAddr().String(), - }, - } - - // 创建 hook 并调用 OnConnect - hook := &meshtasticFilterHook{} - pk := packets.Packet{} - - err = hook.OnConnect(cl, pk) - if err != nil { - t.Fatalf("OnConnect failed: %v", err) - } - - // 验证 TCP_NODELAY 是否设置 - if tcpConn, ok := serverConn.(*net.TCPConn); ok { - // 这里我们无法直接读取 TCP_NODELAY 的值,但可以验证没有错误 - // 实际上,我们可以通过设置后再次设置来验证 - err := tcpConn.SetNoDelay(false) - if err != nil { - t.Fatalf("Failed to set NoDelay to false: %v", err) - } - err = tcpConn.SetNoDelay(true) - if err != nil { - t.Fatalf("Failed to set NoDelay to true: %v", err) - } - t.Log("TCP_NODELAY successfully set") - } else { - t.Fatal("Connection is not a TCP connection") - } -} - -// TestQoS0MessageLatency 测试 QoS0 消息的响应延迟 -func TestQoS0MessageLatency(t *testing.T) { - // 创建一个简单的 TCP echo 服务器来模拟 MQTT - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to create listener: %v", err) - } - defer listener.Close() - - addr := listener.Addr().String() - - // 启动服务器 - go func() { - conn, err := listener.Accept() - if err != nil { - return - } - defer conn.Close() - - // 设置 TCP_NODELAY - if tcpConn, ok := conn.(*net.TCPConn); ok { - tcpConn.SetNoDelay(true) - } - - buf := make([]byte, 1024) - for { - n, err := conn.Read(buf) - if err != nil { - return - } - // 立即写回(模拟 ACK) - _, err = conn.Write(buf[:n]) - if err != nil { - return - } - } - }() - - // 客户端连接 - conn, err := net.Dial("tcp", addr) - if err != nil { - t.Fatalf("Failed to dial: %v", err) - } - defer conn.Close() - - // 测试小数据包的延迟 - testData := []byte("test") - samples := 10 - var totalLatency time.Duration - - for i := 0; i < samples; i++ { - start := time.Now() - - _, err := conn.Write(testData) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - buf := make([]byte, len(testData)) - _, err = conn.Read(buf) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - - latency := time.Since(start) - totalLatency += latency - t.Logf("Round trip %d: %v", i+1, latency) - } - - avgLatency := totalLatency / time.Duration(samples) - t.Logf("Average latency: %v", avgLatency) - - // 平均延迟应该小于 10ms(如果没有 Nagle 算法延迟) - if avgLatency > 10*time.Millisecond { - t.Logf("Warning: Average latency %v is higher than expected, may indicate Nagle's algorithm is active", avgLatency) - } -}