Baseline note: TestMetricHistoryMinuteFold fails identically on the pristine B3 tip in the same wall-clock window (minute-parity sensitive seed), passes in others — pre-existing, untouched path.
139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
|
)
|
|
|
|
// Item 7 B4: the agent-facing wire — probe_tasks rides the config
|
|
// response (always present, empty list clears), and probeResults on
|
|
// the heartbeat are filed stamped at arrival, with junk rows
|
|
// (unknown/absent latency, non-positive task ids) skipped and
|
|
// unassigned tasks dropped by the store's assignment gate.
|
|
func TestAgentProbeTasksWire(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: "b4-wire"}
|
|
if err := db.Create(node).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const token = "b4-wire-token"
|
|
agent := &model.AgentNode{
|
|
TokenHash: hashAgentToken(token), TokenPrefix: "b4-",
|
|
Name: "b4-wire-agent", NodeID: &node.Id,
|
|
}
|
|
if err := db.Create(agent).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agentID := int64(agent.Id)
|
|
probe.DeleteAgentData(agentID)
|
|
|
|
taskID, err := probe.SavePingTask(probe.PingTask{
|
|
Name: "wire", Target: "1.1.1.1:443", Interval: 30,
|
|
AgentIDs: []int64{agentID},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
NewAgentController(engine.Group("/panel/api/agent"))
|
|
do := func(method, path, body string) (int, []byte) {
|
|
var rd *bytes.Reader
|
|
if body != "" {
|
|
rd = bytes.NewReader([]byte(body))
|
|
} else {
|
|
rd = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, path, rd)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
engine.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Body.Bytes()
|
|
}
|
|
|
|
const cfgPath = "/panel/api/agent/config"
|
|
const hbPath = "/panel/api/agent/heartbeat"
|
|
|
|
// Config carries the task set.
|
|
code, body := do(http.MethodGet, cfgPath, "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("config: %d %s", code, body)
|
|
}
|
|
var cfg struct {
|
|
ProbeTasks []probe.PingTaskRef `json:"probe_tasks"`
|
|
}
|
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(cfg.ProbeTasks) != 1 || cfg.ProbeTasks[0].ID != taskID ||
|
|
cfg.ProbeTasks[0].Target != "1.1.1.1:443" || cfg.ProbeTasks[0].Interval != 30 {
|
|
t.Fatalf("config probe_tasks: %s", body)
|
|
}
|
|
|
|
// Heartbeat with a mixed batch: only the valid, assigned sample
|
|
// lands. (All rows share the arrival stamp, so the two filings of
|
|
// the same task collapse — assert on the surviving value.)
|
|
hb := `{"probeResults":[` +
|
|
`{"taskId":` + itoa(taskID) + `,"latencyMs":42},` +
|
|
`{"taskId":999999,"latencyMs":5},` +
|
|
`{"taskId":-3,"latencyMs":5},` +
|
|
`{"taskId":` + itoa(taskID) + `}` +
|
|
`]}`
|
|
code, body = do(http.MethodPost, hbPath, hb)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("heartbeat: %d %s", code, body)
|
|
}
|
|
st := probe.Default()
|
|
var count int64
|
|
var latency int64
|
|
if err := st.DB().QueryRow(`SELECT COUNT(*), COALESCE(MAX(latency), -999) FROM probe_ping_record WHERE agent_id = ?`, agentID).Scan(&count, &latency); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 || latency != 42 {
|
|
t.Fatalf("filed rows %d latency %d", count, latency)
|
|
}
|
|
|
|
// After the task is unassigned, config reports an empty (present)
|
|
// list and further results are dropped by the assignment gate.
|
|
if err := probe.SetNodePingTasks(agentID, []int64{}, []int64{taskID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, body = do(http.MethodGet, cfgPath, "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("config 2: %d", code)
|
|
}
|
|
if !bytes.Contains(body, []byte(`"probe_tasks":[]`)) {
|
|
t.Fatalf("empty probe_tasks not present: %s", body)
|
|
}
|
|
code, _ = do(http.MethodPost, hbPath, `{"probeResults":[{"taskId":`+itoa(taskID)+`,"latencyMs":77}]}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("heartbeat 2: %d", code)
|
|
}
|
|
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_ping_record WHERE agent_id = ? AND latency = 77`, agentID).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatal("unassigned result was filed")
|
|
}
|
|
probe.DeletePingTask(taskID)
|
|
}
|