package rest import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "time" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/rest/model" ) func TestNativeInvokeWithoutRESTListener(t *testing.T) { cfg := &model.StartConfig{ NativeMode: true, Storage: model.StorageMem, } port, err := Start(cfg) if err != nil { t.Fatal(err) } defer Stop() if port != 0 { t.Fatalf("native runtime opened REST port %d by default", port) } var result model.Result[json.RawMessage] if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { t.Fatalf("invoke failed: %#v", result) } } func TestStartAPIServerIsNotBlockedByPendingDispatch(t *testing.T) { requestStarted := make(chan struct{}) releaseRequest := make(chan struct{}) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(releaseRequest) }) } upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { close(requestStarted) <-releaseRequest w.Header().Set("Content-Length", "0") })) t.Cleanup(func() { release() upstream.Close() Stop() }) if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil { t.Fatal(err) } requestBody, err := json.Marshal(&model.ResolveTask{Req: &base.Request{URL: upstream.URL}}) if err != nil { t.Fatal(err) } dispatchDone := make(chan struct{}) go func() { defer close(dispatchDone) Dispatch(http.MethodPost, "/api/v1/resolve", "", string(requestBody)) }() select { case <-requestStarted: case <-time.After(time.Second): t.Fatal("resolve request did not start") } config, err := Downloader.GetConfig() if err != nil { t.Fatal(err) } config.API = &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"} if err := Downloader.PutConfig(config); err != nil { t.Fatal(err) } started := make(chan *model.APIServerState, 1) startErrors := make(chan error, 1) go func() { state, err := StartAPIServer() started <- state startErrors <- err }() select { case state := <-started: if err := <-startErrors; err != nil { t.Fatalf("start API server failed: %v", err) } if !state.Running || state.RunningPort == 0 { t.Fatalf("API server did not start: %#v", state) } case <-time.After(time.Second): t.Fatal("pending Resolve blocked API server startup") } release() select { case <-dispatchDone: case <-time.After(time.Second): t.Fatal("resolve request did not finish after release") } } func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) { port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}) if err != nil { t.Fatal(err) } defer Stop() if port != 0 { t.Fatalf("native runtime opened REST port %d by default", port) } putAPIServerConfig(t, &base.APIServerConfig{ Enable: true, MCPEnable: true, Network: "tcp", Address: "127.0.0.1:0", Token: "secret", }) state, err := StartAPIServer() if err != nil { t.Fatal(err) } port = state.RunningPort if port == 0 { t.Fatal("enabling the REST API did not open a TCP listener") } if !state.MCPEnabled { t.Fatal("persisted MCP endpoint setting was not applied") } request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port), nil) if err != nil { t.Fatal(err) } request.Header.Set("X-Api-Token", "secret") response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("REST API returned status %d", response.StatusCode) } mcpRequest, err := http.NewRequest( http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/mcp", port), strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"gopeed-test","version":"1.0.0"}}}`), ) if err != nil { t.Fatal(err) } mcpRequest.Header.Set("Accept", "application/json, text/event-stream") mcpRequest.Header.Set("Content-Type", "application/json") mcpRequest.Header.Set("Authorization", "Bearer secret") mcpResponse, err := http.DefaultClient.Do(mcpRequest) if err != nil { t.Fatal(err) } mcpResponse.Body.Close() if mcpResponse.StatusCode != http.StatusOK { t.Fatalf("MCP endpoint returned status %d", mcpResponse.StatusCode) } putAPIServerConfig(t, &base.APIServerConfig{Enable: false}) if state, err := StopAPIServer(); err != nil { t.Fatal(err) } else if state.Running || state.MCPEnabled || state.RunningPort != 0 || state.PendingApply { t.Fatalf("unexpected stopped API server state: %#v", state) } var result model.Result[json.RawMessage] if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { t.Fatalf("in-process API stopped with REST listener: %#v", result) } if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port)); err == nil { t.Fatal("REST listener still accepted connections after it was disabled") } } func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) { if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil { t.Fatal(err) } defer Stop() putAPIServerConfig(t, &base.APIServerConfig{ Enable: true, Network: "tcp", Address: "127.0.0.1:0", Token: "secret", }) startedState, err := StartAPIServer() if err != nil { t.Fatal(err) } previousPort := startedState.RunningPort putAPIServerConfig(t, &base.APIServerConfig{ Enable: true, Network: "unsupported-network", Address: "invalid", }) state, err := RestartAPIServer() if err == nil { t.Fatal("invalid listener configuration unexpectedly succeeded") } if state.Running || state.RunningPort != 0 || !state.PendingApply || state.LastError == "" { t.Fatalf("failed restart did not leave REST listener stopped: %#v", state) } if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", previousPort)); err == nil { t.Fatal("previous REST listener still accepted connections after failed restart") } var result model.Result[json.RawMessage] if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { t.Fatalf("in-process API unavailable after failed REST restart: %#v", result) } } func TestNativeRESTSettingPersistsInGoStorage(t *testing.T) { storageDir := filepath.Join(t.TempDir(), "storage") start := func() int { port, err := Start(&model.StartConfig{ NativeMode: true, Storage: model.StorageBolt, StorageDir: storageDir, }) if err != nil { t.Fatal(err) } return port } if port := start(); port != 0 { t.Fatalf("first native start opened REST port %d", port) } config, err := Downloader.GetConfig() if err != nil { t.Fatal(err) } config.API = newAPIServerConfigForTest() if err := Downloader.PutConfig(config); err != nil { t.Fatal(err) } Stop() if port := start(); port == 0 { Stop() t.Fatal("persisted REST setting did not open a listener") } Stop() } func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) { storageDir := filepath.Join(t.TempDir(), "storage") startConfig := &model.StartConfig{ NativeMode: true, Storage: model.StorageBolt, StorageDir: storageDir, } if _, err := Start(startConfig); err != nil { t.Fatal(err) } putAPIServerConfig(t, &base.APIServerConfig{ Enable: true, Network: "unsupported-network", Address: "invalid", }) Stop() port, err := Start(startConfig) if err != nil { t.Fatalf("optional REST listener failure stopped native core startup: %v", err) } defer Stop() if port != 0 { t.Fatalf("failed REST listener reported running port %d", port) } state, err := GetAPIServerState() if err != nil { t.Fatal(err) } if !state.Enabled || state.Running || !state.PendingApply || state.LastError == "" { t.Fatalf("unexpected state after optional REST listener failure: %#v", state) } var result model.Result[json.RawMessage] if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { t.Fatalf("in-process API unavailable after REST listener failure: %#v", result) } } func newAPIServerConfigForTest() *base.APIServerConfig { return &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"} } func putAPIServerConfig(t *testing.T, apiConfig *base.APIServerConfig) { t.Helper() config, err := Downloader.GetConfig() if err != nil { t.Fatal(err) } config.API = apiConfig if err := Downloader.PutConfig(config); err != nil { t.Fatal(err) } }