package agent import ( "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/mhsanaei/3x-ui/v3/internal/logger" ) // stubRegisterWait replaces the retry sleep with a recorder that never // actually sleeps. allowWaits is how many waits return true (keep // retrying) before returning false (agent stopping). func stubRegisterWait(t *testing.T, allowWaits int) *[]time.Duration { t.Helper() delays := &[]time.Duration{} orig := registerWait registerWait = func(_ chan struct{}, d time.Duration) bool { *delays = append(*delays, d) return len(*delays) <= allowWaits } t.Cleanup(func() { registerWait = orig }) return delays } func captureLogs(t *testing.T) *[]string { t.Helper() var mu sync.Mutex lines := &[]string{} prev := logger.SetLogHook(func(_, msg string) { mu.Lock() *lines = append(*lines, msg) mu.Unlock() }) t.Cleanup(func() { logger.SetLogHook(prev) }) return lines } // scriptedMaster answers register requests from a status-code script; // once the script is exhausted it answers 200. func scriptedMaster(t *testing.T, script []int, hits *int) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/panel/api/agent/register") { w.WriteHeader(http.StatusNotFound) return } *hits++ code := http.StatusOK if *hits <= len(script) { code = script[*hits-1] } w.WriteHeader(code) fmt.Fprint(w, `{}`) })) t.Cleanup(srv.Close) return srv } func TestRegistrationBackoffSequence(t *testing.T) { want := []time.Duration{5, 15, 30, 60, 60, 60} for i, w := range want { if got := registrationBackoff(i + 1); got != w*time.Second { t.Errorf("registrationBackoff(%d) = %s, want %ss", i+1, got, w) } } } func TestRegisterWithRetrySucceedsAfter404s(t *testing.T) { hits := 0 srv := scriptedMaster(t, []int{404, 404}, &hits) delays := stubRegisterWait(t, 10) logs := captureLogs(t) ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) if err := ag.registerWithRetry(); err != nil { t.Fatalf("registerWithRetry: %v", err) } if hits != 3 { t.Fatalf("register hits = %d, want 3 (404, 404, 200)", hits) } if len(*delays) != 2 || (*delays)[0] != 5*time.Second || (*delays)[1] != 15*time.Second { t.Fatalf("retry delays = %v, want [5s 15s]", *delays) } joined := strings.Join(*logs, "\n") if !strings.Contains(joined, "检查 master_url 是否包含面板基础路径") { t.Errorf("404 hint missing from logs:\n%s", joined) } if !strings.Contains(joined, "retrying in 5s") { t.Errorf("retry log line missing:\n%s", joined) } } func TestRegisterWithRetryAbortsOnStop(t *testing.T) { hits := 0 srv := scriptedMaster(t, []int{500, 500, 500, 500, 500, 500, 500, 500}, &hits) // First wait says "stopping": the loop must give up immediately // instead of burning through the script. delays := stubRegisterWait(t, 0) ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) err := ag.registerWithRetry() if !errors.Is(err, errRegistrationAborted) { t.Fatalf("registerWithRetry err = %v, want errRegistrationAborted", err) } if hits != 1 { t.Fatalf("register hits = %d, want 1 (no retry after stop)", hits) } if len(*delays) != 1 { t.Fatalf("waits = %d, want 1", len(*delays)) } } func TestRegisterStatusErrorCarriesCode(t *testing.T) { hits := 0 srv := scriptedMaster(t, []int{404}, &hits) ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) err := ag.Register() var st *statusError if !errors.As(err, &st) { t.Fatalf("Register err = %v (%T), want *statusError", err, err) } if st.code != http.StatusNotFound { t.Fatalf("statusError.code = %d, want 404", st.code) } // Error text must stay the historical one — scripts may grep for it. if !strings.Contains(err.Error(), "register failed with status 404") { t.Fatalf("error text changed: %q", err.Error()) } }