diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index baac4b4..702cde2 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -92,7 +92,13 @@ def send_request( response = httpx.delete( url, headers=headers, params=params, timeout=self.timeout ) - elif action == Action.LIST or action == Action.GET: + elif action == Action.LIST: + # Fess admin list endpoints bind size, page and filters from the + # JSON request body and ignore the query string, so send both. + response = httpx.request( + "GET", url, headers=headers, params=params, json=params, timeout=self.timeout + ) + elif action == Action.GET: response = httpx.get( url, headers=headers, params=params, timeout=self.timeout ) diff --git a/tests/commands/test_scheduler.py b/tests/commands/test_scheduler.py index 99da2c3..1a3871a 100644 --- a/tests/commands/test_scheduler.py +++ b/tests/commands/test_scheduler.py @@ -95,6 +95,32 @@ def test_scheduler_crud_flow(runner, fess_service): assert "failed to retrieve scheduler" in result.stdout.lower() +def test_scheduler_list_size_and_page(runner, fess_service): + """ + Tests that --size limits the rows returned and --page moves through them. + A fresh Fess ships with several built-in jobs, so there is more than one row. + """ + result = runner.invoke( + scheduler_app, + ["list", "--size", "1", "--page", "1", "--output", "json"] + ) + assert result.exit_code == 0, f"List page 1 failed: {result.stdout}" + page1 = json.loads(result.stdout)["response"] + assert page1.get("status") == 0 + assert page1.get("total", 0) > 1, "Expected more than one scheduler on a fresh Fess" + assert len(page1["settings"]) == 1 + + result = runner.invoke( + scheduler_app, + ["list", "--size", "1", "--page", "2", "--output", "json"] + ) + assert result.exit_code == 0, f"List page 2 failed: {result.stdout}" + page2 = json.loads(result.stdout)["response"] + assert page2.get("status") == 0 + assert len(page2["settings"]) == 1 + assert page2["settings"][0]["id"] != page1["settings"][0]["id"] + + def test_scheduler_start_stop_json_output(runner, fess_service): """ Tests the start and stop commands return valid JSON responses. diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b9414bf..c69bd7d 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -2,6 +2,7 @@ Unit tests for fessctl.api.client module. """ import json +from types import SimpleNamespace from unittest.mock import Mock, patch, MagicMock import httpx @@ -201,16 +202,17 @@ def test_delete_uses_delete(self, mock_delete, client): mock_delete.assert_called_once() - @patch("httpx.get") - def test_list_uses_get(self, mock_get, client): + @patch("httpx.request") + def test_list_uses_get(self, mock_request, client): """Test that LIST action uses GET method.""" mock_response = Mock() mock_response.json.return_value = {"response": {"status": 0}} - mock_get.return_value = mock_response + mock_request.return_value = mock_response client.send_request(Action.LIST, "http://test/api") - mock_get.assert_called_once() + mock_request.assert_called_once() + assert mock_request.call_args[0][0] == "GET" @patch("httpx.get") def test_get_uses_get(self, mock_get, client): @@ -268,6 +270,59 @@ def test_stop_fess14_uses_post(self, mock_post, client_v14): mock_post.assert_called_once() +LIST_METHODS = sorted( + name for name in dir(FessAPIClient) if name.startswith("list_")) + + +@pytest.fixture +def http(): + """Patch httpx.get and httpx.request, both returning a successful response.""" + response = Mock() + response.json.return_value = {"response": {"status": 0}} + with patch("httpx.get", return_value=response) as mock_get, \ + patch("httpx.request", return_value=response) as mock_request: + yield SimpleNamespace(get=mock_get, request=mock_request) + + +class TestListPaging: + """Fess admin list endpoints bind size, page and filters from the JSON request body.""" + + def test_list_sends_params_in_json_body_and_query_string(self, client, http): + params = {"page": 2, "size": 1} + + client.send_request(Action.LIST, "http://test/api", params=params) + + http.get.assert_not_called() + http.request.assert_called_once() + assert http.request.call_args[0] == ("GET", "http://test/api") + call_kwargs = http.request.call_args[1] + assert call_kwargs["json"] == {"page": 2, "size": 1} + assert call_kwargs["params"] == {"page": 2, "size": 1} + + def test_list_without_params_sends_no_body(self, client, http): + client.send_request(Action.LIST, "http://test/api") + + call_kwargs = http.request.call_args[1] + assert call_kwargs["json"] is None + assert call_kwargs["params"] is None + + def test_get_carries_no_body(self, client, http): + client.send_request(Action.GET, "http://test/api/role/abc") + + http.request.assert_not_called() + http.get.assert_called_once() + assert "json" not in http.get.call_args[1] + + @pytest.mark.parametrize("method", LIST_METHODS) + def test_every_list_method_puts_paging_in_body(self, client, http, method): + getattr(client, method)(page=2, size=1) + + http.get.assert_not_called() + call_kwargs = http.request.call_args[1] + assert call_kwargs["json"] == {"page": 2, "size": 1} + assert call_kwargs["params"] == {"page": 2, "size": 1} + + class TestSendRequestErrorHandling: """Tests for error handling in send_request.""" @@ -533,16 +588,16 @@ def test_get_role(self, mock_get, client): call_url = mock_get.call_args[0][0] assert "role-123" in call_url - @patch("httpx.get") - def test_list_roles_with_pagination(self, mock_get, client): + @patch("httpx.request") + def test_list_roles_with_pagination(self, mock_request, client): """Test listing roles with pagination.""" mock_response = Mock() mock_response.json.return_value = {"response": {"status": 0, "settings": []}} - mock_get.return_value = mock_response + mock_request.return_value = mock_response client.list_roles(page=2, size=50) - call_kwargs = mock_get.call_args[1] + call_kwargs = mock_request.call_args[1] assert call_kwargs["params"]["page"] == 2 assert call_kwargs["params"]["size"] == 50