package panel import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/cedar2025/xboard-node/internal/config" ) func newTestServer(handler http.HandlerFunc) (*httptest.Server, *Client) { ts := httptest.NewServer(handler) client := NewClient(config.PanelConfig{ URL: ts.URL, Token: "test-token", NodeID: 1, }) return ts, client } func TestGetConfig_Success(t *testing.T) { ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/server/UniProxy/config" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("token") != "test-token" { t.Errorf("missing token in query") } if r.URL.Query().Get("node_id") != "1" { t.Errorf("missing node_id in query") } w.Header().Set("ETag", `"etag-1"`) json.NewEncoder(w).Encode(NodeConfig{ Protocol: "shadowsocks", ServerPort: 111, Cipher: "aes-128-gcm", }) }) defer ts.Close() cfg, err := client.GetConfig() if err != nil { t.Fatalf("GetConfig: %v", err) } if cfg == nil { t.Fatal("expected non-nil config") } if cfg.Protocol != "shadowsocks" { t.Errorf("protocol: got %q", cfg.Protocol) } if cfg.ServerPort != 111 { t.Errorf("server_port: got %d", cfg.ServerPort) } } func TestGetConfig_NotModified(t *testing.T) { callCount := 0 ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount == 1 { w.Header().Set("ETag", `"etag-1"`) json.NewEncoder(w).Encode(NodeConfig{Protocol: "shadowsocks"}) return } if r.Header.Get("If-None-Match") != `"etag-1"` { t.Errorf("expected If-None-Match header, got %q", r.Header.Get("If-None-Match")) } w.WriteHeader(http.StatusNotModified) }) defer ts.Close() // First call — get config cfg, err := client.GetConfig() if err != nil || cfg == nil { t.Fatalf("first GetConfig: err=%v cfg=%v", err, cfg) } // Second call — should be 304 cfg, err = client.GetConfig() if err != nil { t.Fatalf("second GetConfig: %v", err) } if cfg != nil { t.Error("expected nil config for 304") } } func TestGetConfig_ServerError(t *testing.T) { ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("internal error")) }) defer ts.Close() _, err := client.GetConfig() if err == nil { t.Fatal("expected error for 500 response") } } func TestGetUsers_Success(t *testing.T) { ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/server/UniProxy/user" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("ETag", `"users-etag-1"`) json.NewEncoder(w).Encode(UsersResponse{ Users: []User{ {ID: 1, UUID: "uuid-1", SpeedLimit: 3, DeviceLimit: 2}, {ID: 2, UUID: "uuid-2", SpeedLimit: 0, DeviceLimit: 0}, }, }) }) defer ts.Close() users, err := client.GetUsers() if err != nil { t.Fatalf("GetUsers: %v", err) } if len(users) != 2 { t.Fatalf("users count: got %d", len(users)) } if users[0].UUID != "uuid-1" { t.Errorf("user[0].uuid: got %q", users[0].UUID) } } func TestGetUsers_NotModified(t *testing.T) { callCount := 0 ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount == 1 { w.Header().Set("ETag", `"u-etag"`) json.NewEncoder(w).Encode(UsersResponse{Users: []User{{ID: 1, UUID: "u1"}}}) return } w.WriteHeader(http.StatusNotModified) }) defer ts.Close() users, _ := client.GetUsers() if len(users) != 1 { t.Fatalf("first call: got %d users", len(users)) } users, err := client.GetUsers() if err != nil { t.Fatalf("second GetUsers: %v", err) } if users != nil { t.Error("expected nil for 304") } } func TestPushTraffic_Success(t *testing.T) { var received map[string]interface{} ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/api/v1/server/UniProxy/push" { t.Errorf("unexpected path: %s", r.URL.Path) } json.NewDecoder(r.Body).Decode(&received) w.WriteHeader(http.StatusOK) }) defer ts.Close() data := map[int][2]int64{ 1: {1024, 2048}, 2: {512, 256}, } if err := client.PushTraffic(data); err != nil { t.Fatalf("PushTraffic: %v", err) } if received == nil { t.Fatal("server received nil payload") } // Verify token was injected if received["token"] != "test-token" { t.Errorf("token: got %v", received["token"]) } } func TestPushTraffic_Empty(t *testing.T) { called := false ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(http.StatusOK) }) defer ts.Close() if err := client.PushTraffic(nil); err != nil { t.Fatalf("PushTraffic nil: %v", err) } if called { t.Error("should not call server for empty traffic") } } func TestPushAlive_Success(t *testing.T) { var received map[string]interface{} ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/server/UniProxy/alive" { t.Errorf("unexpected path: %s", r.URL.Path) } json.NewDecoder(r.Body).Decode(&received) w.WriteHeader(http.StatusOK) }) defer ts.Close() data := map[int][]string{ 1: {"1.2.3.4", "5.6.7.8"}, } if err := client.PushAlive(data); err != nil { t.Fatalf("PushAlive: %v", err) } if received == nil { t.Fatal("server received nil") } } func TestPushStatus_Success(t *testing.T) { var received map[string]interface{} ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/server/UniProxy/status" { t.Errorf("unexpected path: %s", r.URL.Path) } json.NewDecoder(r.Body).Decode(&received) w.WriteHeader(http.StatusOK) }) defer ts.Close() err := client.PushStatus(50.0, [2]uint64{8192, 4096}, [2]uint64{2048, 1024}, [2]uint64{100000, 50000}) if err != nil { t.Fatalf("PushStatus: %v", err) } if received["cpu"].(float64) != 50.0 { t.Errorf("cpu: got %v", received["cpu"]) } } func TestResetETags(t *testing.T) { callCount := 0 ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount <= 2 { w.Header().Set("ETag", `"e1"`) json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"}) return } // After reset, should NOT send If-None-Match if r.Header.Get("If-None-Match") != "" { t.Errorf("expected no If-None-Match after reset, got %q", r.Header.Get("If-None-Match")) } json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"}) }) defer ts.Close() client.GetConfig() client.ResetETags() client.GetConfig() } func TestPushTraffic_ServerError(t *testing.T) { ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("unavailable")) }) defer ts.Close() data := map[int][2]int64{1: {100, 200}} err := client.PushTraffic(data) if err == nil { t.Fatal("expected error for 503") } } func TestClient_NodeTypeInQuery(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("node_type") != "vmess" { t.Errorf("node_type: got %q, want vmess", r.URL.Query().Get("node_type")) } json.NewEncoder(w).Encode(NodeConfig{Protocol: "vmess"}) })) defer ts.Close() client := NewClient(config.PanelConfig{ URL: ts.URL, Token: "tok", NodeID: 1, NodeType: "vmess", }) client.GetConfig() }