Files
3x-ui-agent/internal/agent/ping_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

170 lines
5.0 KiB
Go

package agent
import (
"encoding/json"
"net"
"testing"
"time"
)
// probeTCP against a live listener yields a real non-negative
// latency; a closed port and a malformed target both file -1; 0 is
// never a latency (contract: failures are negative or absent).
func TestProbeTCPOutcomes(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
conn.Close()
}
}()
ms, sampled := probeTCP(ln.Addr().String(), nil)
if !sampled || ms < 0 {
t.Fatalf("live target: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("127.0.0.1:1", nil)
if !sampled || ms != -1 {
t.Fatalf("closed port: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("not-a-target", nil)
if !sampled || ms != -1 {
t.Fatalf("malformed: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("256.256.256.256:80", nil)
if !sampled || ms != -1 {
t.Fatalf("unresolvable: ms=%d sampled=%v", ms, sampled)
}
}
// applyTasks: an unchanged task keeps its loop; a changed target or
// interval restarts it; a removed task is cancelled; the set is
// truncated at the cap with intervals clamped into range.
func TestProbeRunnerApplyTasks(t *testing.T) {
r := newProbeRunner()
defer r.stop()
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 60}})
if len(r.tasks) != 1 {
t.Fatalf("tasks %v", r.tasks)
}
first := r.tasks[1]
// Unchanged: same loop object survives.
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 60}})
if r.tasks[1] != first {
t.Fatal("unchanged task restarted")
}
// Changed interval: replaced, clamped values are what runs.
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 120}})
if r.tasks[1] == first || r.tasks[1].interval != 120 {
t.Fatal("changed task not restarted")
}
// Removed: cancelled.
r.applyTasks(nil)
if len(r.tasks) != 0 {
t.Fatalf("tasks after clear %v", r.tasks)
}
// Clamp + cap.
many := make([]ProbeTask, 0, 70)
for i := 1; i <= 70; i++ {
many = append(many, ProbeTask{ID: int64(i), Target: "127.0.0.1:1", Interval: 1})
}
r.applyTasks(many)
if len(r.tasks) != maxProbeTasksRun {
t.Fatalf("cap: %d running", len(r.tasks))
}
if r.tasks[1].interval != minProbeIntervalSec {
t.Fatalf("clamp: interval %d", r.tasks[1].interval)
}
}
// A running loop files samples into the buffer; drain takes them and
// requeue restores order ahead of newer samples.
func TestProbeRunnerResultFlow(t *testing.T) {
r := newProbeRunner()
defer r.stop()
r.applyTasks([]ProbeTask{{ID: 9, Target: "127.0.0.1:1", Interval: 3600}})
// The loop probes immediately on start.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if got := r.drainResults(); len(got) > 0 {
if got[0].TaskID != 9 || got[0].LatencyMs != -1 {
t.Fatalf("sample %+v", got[0])
}
goto drained
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("no immediate sample from a fresh task")
drained:
r.addResult(ProbeResult{TaskID: 1, LatencyMs: 5})
batch := r.drainResults()
r.addResult(ProbeResult{TaskID: 1, LatencyMs: 7})
r.requeueResults(batch)
got := r.drainResults()
if len(got) != 2 || got[0].LatencyMs != 5 || got[1].LatencyMs != 7 {
t.Fatalf("requeue order %+v", got)
}
if again := r.drainResults(); again != nil {
t.Fatalf("second drain %+v", again)
}
}
// Buffer overflow drops the oldest samples, keeping the newest cap.
func TestProbeRunnerBufferCap(t *testing.T) {
r := newProbeRunner()
defer r.stop()
for i := 0; i < probeResultCap+10; i++ {
r.addResult(ProbeResult{TaskID: 1, LatencyMs: int64(i)})
}
got := r.drainResults()
if len(got) != probeResultCap {
t.Fatalf("buffer %d", len(got))
}
if got[0].LatencyMs != 10 || got[len(got)-1].LatencyMs != int64(probeResultCap+9) {
t.Fatalf("kept range %d..%d", got[0].LatencyMs, got[len(got)-1].LatencyMs)
}
if r.dropped != 10 {
t.Fatalf("dropped %d", r.dropped)
}
}
// The wire shapes: a result marshals as taskId/latencyMs, and the
// config decode distinguishes an absent probe_tasks (old master,
// keep current set) from an empty one (clear all).
func TestProbeWireShapes(t *testing.T) {
body, err := json.Marshal(ProbeResult{TaskID: 3, LatencyMs: -1})
if err != nil || string(body) != `{"taskId":3,"latencyMs":-1}` {
t.Fatalf("result wire: %s %v", body, err)
}
decode := func(s string) *[]ProbeTask {
var result struct {
ProbeTasks *[]ProbeTask `json:"probe_tasks"`
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
t.Fatal(err)
}
return result.ProbeTasks
}
if got := decode(`{"config_hash":"x"}`); got != nil {
t.Fatalf("absent field decoded as %v", got)
}
if got := decode(`{"probe_tasks":[]}`); got == nil || len(*got) != 0 {
t.Fatalf("empty list decoded as %v", got)
}
got := decode(`{"probe_tasks":[{"id":2,"target":"1.1.1.1:443","interval":30}]}`)
if got == nil || len(*got) != 1 || (*got)[0].ID != 2 || (*got)[0].Target != "1.1.1.1:443" || (*got)[0].Interval != 30 {
t.Fatalf("task decode %+v", got)
}
}