package controller import ( "bytes" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/gin-gonic/gin" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) // P13: the heartbeat optionally carries a telemetry object. Reported fields // are persisted onto the agent row; a heartbeat without telemetry (older // agents) must not clear previously stored values, and a partial report // only overwrites the fields it carries. func TestAgentHeartbeatTelemetry(t *testing.T) { gin.SetMode(gin.TestMode) dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { t.Fatalf("InitDB: %v", err) } t.Cleanup(func() { _ = database.CloseDB() }) db := database.GetDB() node := &model.Node{Name: "telemetry-node", Address: "10.0.9.1"} if err := db.Create(node).Error; err != nil { t.Fatalf("seed node: %v", err) } const token = "telemetry-test-token" agent := &model.AgentNode{ TokenHash: hashAgentToken(token), TokenPrefix: "telemetry-", Name: "telemetry-node", NodeID: &node.Id, } if err := db.Create(agent).Error; err != nil { t.Fatalf("seed agent: %v", err) } engine := gin.New() NewAgentController(engine.Group("/panel/api/agent")) post := func(body string) int { req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) return rec.Code } load := func() model.AgentNode { var row model.AgentNode if err := db.First(&row, agent.Id).Error; err != nil { t.Fatalf("reload agent: %v", err) } return row } full := `{"token":"` + token + `","timestamp":1,"telemetry":{` + `"agentVersion":"3.7.0-agent.1","xrayVersion":"26.6.27","uptimeSeconds":90061,` + `"latencyMs":12,"cpuPercent":3.5,"memPercent":41.2,` + `"ipv4":"203.0.113.7","ipv6":"2001:db8::7"}}` if code := post(full); code != http.StatusOK { t.Fatalf("heartbeat with telemetry: status %d", code) } row := load() if row.AgentVersion != "3.7.0-agent.1" || row.XrayVersion != "26.6.27" || row.UptimeSecs != 90061 || row.LatencyMs != 12 || row.CpuPct != 3.5 || row.MemPct != 41.2 || row.IPv4 != "203.0.113.7" || row.IPv6 != "2001:db8::7" || row.TelemetryAt == 0 { t.Fatalf("telemetry not stored: %+v", row) } // No telemetry object (old agent): stored values survive. if code := post(`{"token":"` + token + `","timestamp":2}`); code != http.StatusOK { t.Fatalf("bare heartbeat: status %d", code) } row = load() if row.XrayVersion != "26.6.27" || row.IPv4 != "203.0.113.7" || row.UptimeSecs != 90061 { t.Fatalf("bare heartbeat cleared telemetry: %+v", row) } // Partial report: only the carried field changes. if code := post(`{"token":"` + token + `","timestamp":3,"telemetry":{"cpuPercent":9.25}}`); code != http.StatusOK { t.Fatalf("partial heartbeat: status %d", code) } row = load() if row.CpuPct != 9.25 || row.MemPct != 41.2 || row.LatencyMs != 12 { t.Fatalf("partial telemetry merged wrong: %+v", row) } }