Files
3x-ui-agent/internal/web/controller/probe_tasks_test.go
T
Muse 0d1607c358 B4 tests: probe store, agent prober, controller + wire
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.
2026-10-22 22:09:11 +00:00

282 lines
9.4 KiB
Go

package controller
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"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 controller tests: probe task management endpoints (hub
// validation chain, node-id translation, base-diff, reorder), the
// metrics series=ping read going live, and heartbeat ingestion of
// probeResults + config delivery of probe_tasks.
type envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
func decodeEnvelope(t *testing.T, body []byte) envelope {
t.Helper()
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("envelope: %v (%s)", err, body)
}
return env
}
func cleanPingTasks(t *testing.T) {
t.Helper()
tasks, err := probe.PingTasks()
if err != nil {
t.Fatal(err)
}
for _, task := range tasks {
if err := probe.DeletePingTask(task.ID); err != nil {
t.Fatal(err)
}
}
}
const tasksPath = "/panel/api/agent/probe-tasks"
func TestProbeTasksAuthAndValidation(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
// Anonymous callers are refused by the group auth.
rec := rig.do(http.MethodPut, tasksPath, []byte(`{"name":"x","target":"1.1.1.1:443","interval":60}`), false)
if rec.Code == http.StatusOK && decodeEnvelope(t, rec.Body.Bytes()).Success {
t.Fatal("anonymous save succeeded")
}
cases := []struct {
name string
body string
want string
}{
{"empty name", `{"name":" ","target":"1.1.1.1:443","interval":60}`, "请填写名称和目标"},
{"empty target", `{"name":"x","target":"","interval":60}`, "请填写名称和目标"},
{"long name", `{"name":"` + strings.Repeat("名", 65) + `","target":"1.1.1.1:443","interval":60}`, "名称最多 64 个字"},
{"no port", `{"name":"x","target":"1.1.1.1","interval":60}`, "目标要写成"},
{"empty port", `{"name":"x","target":"1.1.1.1:","interval":60}`, "目标要写成"},
{"port range", `{"name":"x","target":"1.1.1.1:65536","interval":60}`, "目标要写成"},
{"bare v6", `{"name":"x","target":"2606:4700:4700::1111:443","interval":60}`, "目标要写成"},
{"interval low", `{"name":"x","target":"1.1.1.1:443","interval":3}`, "间隔要在 5 到 3600 秒之间"},
{"interval high", `{"name":"x","target":"1.1.1.1:443","interval":3601}`, "间隔要在 5 到 3600 秒之间"},
{"long region", `{"name":"x","target":"1.1.1.1:443","interval":60,"region":"` + strings.Repeat("区", 14) + `"}`, "地区最多 13 个字"},
}
for _, tc := range cases {
rec := rig.do(http.MethodPut, tasksPath, []byte(tc.body), true)
env := decodeEnvelope(t, rec.Body.Bytes())
if env.Success {
t.Errorf("%s: save succeeded, want refusal", tc.name)
continue
}
if !strings.Contains(env.Msg, tc.want) {
t.Errorf("%s: msg %q, want fragment %q", tc.name, env.Msg, tc.want)
}
}
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
t.Fatalf("refused saves leaked %d tasks", len(tasks))
}
// Valid forms: bracketed v6, boundary interval, 13-rune region.
for _, body := range []string{
`{"name":"v6","target":"[2606:4700:4700::1111]:443","interval":5}`,
`{"name":"edge","target":"example.com:65535","interval":3600,"region":"` + strings.Repeat("区", 13) + `"}`,
} {
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("valid save refused: %s (%s)", env.Msg, body)
}
}
cleanPingTasks(t)
}
func TestProbeTasksNodeMappingAndLifecycle(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
agentID, nodeID := rig.seedAgent(t, "b4node", true)
// A node with no agent bound cannot take assignments.
db := database.GetDB()
lone := &model.Node{Name: "b4-lone"}
if err := db.Create(lone).Error; err != nil {
t.Fatal(err)
}
body := `{"name":"t1","target":"1.1.1.1:443","interval":60,"nodeIds":[` + itoa(int64(lone.Id)) + `]}`
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success || !strings.Contains(env.Msg, "未绑定 agent") {
t.Fatalf("unbound node save: %+v", env)
}
// Create assigned to the bound node.
body = `{"name":"t1","target":"1.1.1.1:443","interval":60,"region":"亚洲","nodeIds":[` + itoa(int64(nodeID)) + `]}`
rec = rig.do(http.MethodPut, tasksPath, []byte(body), true)
env := decodeEnvelope(t, rec.Body.Bytes())
if !env.Success {
t.Fatalf("create: %s", env.Msg)
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(env.Obj, &created); err != nil || created.ID == 0 {
t.Fatalf("create obj: %s", env.Obj)
}
refs := probe.PingTasksFor(agentID)
if len(refs) != 1 || refs[0].ID != created.ID {
t.Fatalf("agent view: %v", refs)
}
// The list speaks node ids.
rec = rig.do(http.MethodGet, tasksPath, nil, true)
env = decodeEnvelope(t, rec.Body.Bytes())
var list []probeTaskForm
if err := json.Unmarshal(env.Obj, &list); err != nil || len(list) != 1 {
t.Fatalf("list: %s", env.Obj)
}
if len(list[0].NodeIDs) != 1 || list[0].NodeIDs[0] != int64(nodeID) || list[0].Region != "亚洲" {
t.Fatalf("list form %+v", list[0])
}
// by-node edit: clear the assignment with a base diff.
byNode := `{"tasks":[],"base":[` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPut, tasksPath+"/by-node/"+itoa(int64(nodeID)), []byte(byNode), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("by-node clear: %s", env.Msg)
}
if refs := probe.PingTasksFor(agentID); len(refs) != 0 {
t.Fatalf("after by-node clear: %v", refs)
}
// by-node on an unknown node 404s.
rec = rig.do(http.MethodPut, tasksPath+"/by-node/999999", []byte(`{"tasks":[],"base":[]}`), true)
if rec.Code != http.StatusNotFound {
t.Fatalf("by-node unknown node: %d", rec.Code)
}
// Reorder: second task, flip, duplicates refused.
rec = rig.do(http.MethodPut, tasksPath, []byte(`{"name":"t2","target":"8.8.8.8:53","interval":30}`), true)
env = decodeEnvelope(t, rec.Body.Bytes())
var created2 struct {
ID int64 `json:"id"`
}
_ = json.Unmarshal(env.Obj, &created2)
flip := `{"ids":[` + itoa64(created2.ID) + `,` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(flip), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("reorder: %s", env.Msg)
}
dup := `{"ids":[` + itoa64(created.ID) + `,` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(dup), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success {
t.Fatal("duplicate reorder accepted")
}
tasks, _ := probe.PingTasks()
if len(tasks) != 2 || tasks[0].ID != created2.ID {
t.Fatalf("order after flip %+v", tasks)
}
// Delete clears the agent's view too.
rec = rig.do(http.MethodDelete, tasksPath+"/"+itoa64(created.ID), nil, true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("delete: %s", env.Msg)
}
if tasks, _ := probe.PingTasks(); len(tasks) != 1 {
t.Fatalf("after delete %v", tasks)
}
cleanPingTasks(t)
}
// series=ping serves real rows now: medians from filed samples, the
// probes label map, and whole-window loss for a fully-missed task.
func TestProbeMetricsPingSeriesLive(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
rig.setSetting(t, "public_page", "on")
agentID, _ := rig.seedAgent(t, "b4metrics", true)
good, err := probe.SavePingTask(probe.PingTask{Name: "good", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{agentID}}, nil)
if err != nil {
t.Fatal(err)
}
bad, err := probe.SavePingTask(probe.PingTask{Name: "bad", Target: "127.0.0.1:1", Interval: 60, AgentIDs: []int64{agentID}}, nil)
if err != nil {
t.Fatal(err)
}
st := probe.Default()
now := time.Now().Unix()
for i := 0; i < 3; i++ {
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: good, LatencyMs: 10}}, now-int64(i)*10); err != nil {
t.Fatal(err)
}
}
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: bad, LatencyMs: -1}}, now); err != nil {
t.Fatal(err)
}
rec := rig.do(http.MethodGet, "/api/nodes/"+itoa64(agentID)+"/metrics?series=ping&hours=1", nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("metrics: %d %s", rec.Code, rec.Body.String())
}
var resp struct {
Ping []struct {
TaskID int64 `json:"task_id"`
Latency *int64 `json:"latency"`
Loss *int64 `json:"loss"`
} `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
var goodRow, badRow bool
for _, row := range resp.Ping {
switch row.TaskID {
case good:
goodRow = true
if row.Latency == nil || *row.Latency != 10 {
t.Fatalf("good latency %v", row.Latency)
}
case bad:
badRow = true
if row.Latency != nil {
t.Fatalf("bad latency %v, want null", *row.Latency)
}
if row.Loss == nil || *row.Loss != 100 {
t.Fatalf("bad bucket loss %v", row.Loss)
}
}
}
if !goodRow || !badRow {
t.Fatalf("rows %+v", resp.Ping)
}
if resp.Probes[itoa64(good)] != "good" || resp.Probes[itoa64(bad)] != "bad" {
t.Fatalf("probes %v", resp.Probes)
}
if resp.Loss[itoa64(bad)] != 100.0 {
t.Fatalf("window loss %v", resp.Loss)
}
if _, ok := resp.Loss[itoa64(good)]; ok {
t.Fatalf("good task in loss map %v", resp.Loss)
}
cleanPingTasks(t)
}
func itoa64(n int64) string {
return strings.TrimSpace(jsonNumber(n))
}
func jsonNumber(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}