diff --git a/tests/commands/test_crawlinginfo.py b/tests/commands/test_crawlinginfo.py new file mode 100644 index 0000000..806cb54 --- /dev/null +++ b/tests/commands/test_crawlinginfo.py @@ -0,0 +1,119 @@ +import json +import pytest +from typer.testing import CliRunner + +from fessctl.commands.crawlinginfo import crawlinginfo_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_crawlinginfo_list(runner, fess_service): + """ + Test listing crawling info entries. + CrawlingInfo is typically populated by crawler runs, so we just verify + that the list command executes successfully. + """ + result = runner.invoke( + crawlinginfo_app, + ["list", "--output", "json"] + ) + assert result.exit_code == 0, f"List failed: {result.stdout}" + list_resp = json.loads(result.stdout) + assert list_resp.get("response", {}).get("status") == 0 + # logs may be empty if no crawls have run + logs = list_resp["response"].get("logs", []) + assert isinstance(logs, list) + + +def test_crawlinginfo_list_with_pagination(runner, fess_service): + """ + Test listing crawling info entries with pagination options. + """ + result = runner.invoke( + crawlinginfo_app, + ["list", "--page", "1", "--size", "10", "--output", "json"] + ) + assert result.exit_code == 0, f"List with pagination failed: {result.stdout}" + list_resp = json.loads(result.stdout) + assert list_resp.get("response", {}).get("status") == 0 + + +def test_crawlinginfo_list_yaml_output(runner, fess_service): + """ + Test listing crawling info entries with YAML output format. + """ + result = runner.invoke( + crawlinginfo_app, + ["list", "--output", "yaml"] + ) + assert result.exit_code == 0, f"List YAML failed: {result.stdout}" + # YAML output should contain 'response:' key + assert "response:" in result.stdout or "logs:" in result.stdout + + +def test_crawlinginfo_list_text_output(runner, fess_service): + """ + Test listing crawling info entries with text output format. + """ + result = runner.invoke( + crawlinginfo_app, + ["list", "--output", "text"] + ) + # Should succeed even with text output + assert result.exit_code == 0, f"List text failed: {result.stdout}" + + +def test_crawlinginfo_get_nonexistent(runner, fess_service): + """ + Test getting a non-existent crawling info entry returns an error. + """ + result = runner.invoke( + crawlinginfo_app, + ["get", "nonexistent-id-12345"] + ) + assert result.exit_code != 0 + assert "failed to retrieve crawlinginfo" in result.stdout.lower() + + +def test_crawlinginfo_get_nonexistent_json_output(runner, fess_service): + """ + Test getting a non-existent crawling info entry with JSON output. + """ + result = runner.invoke( + crawlinginfo_app, + ["get", "nonexistent-id-12345", "--output", "json"] + ) + # JSON output always returns, but status should indicate failure + get_resp = json.loads(result.stdout) + assert get_resp.get("response", {}).get("status") != 0 + + +def test_crawlinginfo_delete_nonexistent(runner, fess_service): + """ + Test deleting a non-existent crawling info entry returns an error. + """ + result = runner.invoke( + crawlinginfo_app, + ["delete", "nonexistent-id-12345"] + ) + assert result.exit_code != 0 + assert "failed to delete crawlinginfo" in result.stdout.lower() + + +def test_crawlinginfo_delete_nonexistent_json_output(runner, fess_service): + """ + Test deleting a non-existent crawling info entry with JSON output. + """ + result = runner.invoke( + crawlinginfo_app, + ["delete", "nonexistent-id-12345", "--output", "json"] + ) + # JSON output always returns, but status should indicate failure + del_resp = json.loads(result.stdout) + assert del_resp.get("response", {}).get("status") != 0 diff --git a/tests/commands/test_joblog.py b/tests/commands/test_joblog.py new file mode 100644 index 0000000..77af4c5 --- /dev/null +++ b/tests/commands/test_joblog.py @@ -0,0 +1,119 @@ +import json +import pytest +from typer.testing import CliRunner + +from fessctl.commands.joblog import joblog_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_joblog_list(runner, fess_service): + """ + Test listing job log entries. + JobLogs are populated by scheduler runs, so we just verify + that the list command executes successfully. + """ + result = runner.invoke( + joblog_app, + ["list", "--output", "json"] + ) + assert result.exit_code == 0, f"List failed: {result.stdout}" + list_resp = json.loads(result.stdout) + assert list_resp.get("response", {}).get("status") == 0 + # logs may be empty if no jobs have run + logs = list_resp["response"].get("logs", []) + assert isinstance(logs, list) + + +def test_joblog_list_with_pagination(runner, fess_service): + """ + Test listing job log entries with pagination options. + """ + result = runner.invoke( + joblog_app, + ["list", "--page", "1", "--size", "10", "--output", "json"] + ) + assert result.exit_code == 0, f"List with pagination failed: {result.stdout}" + list_resp = json.loads(result.stdout) + assert list_resp.get("response", {}).get("status") == 0 + + +def test_joblog_list_yaml_output(runner, fess_service): + """ + Test listing job log entries with YAML output format. + """ + result = runner.invoke( + joblog_app, + ["list", "--output", "yaml"] + ) + assert result.exit_code == 0, f"List YAML failed: {result.stdout}" + # YAML output should contain 'response:' key + assert "response:" in result.stdout or "logs:" in result.stdout + + +def test_joblog_list_text_output(runner, fess_service): + """ + Test listing job log entries with text output format. + """ + result = runner.invoke( + joblog_app, + ["list", "--output", "text"] + ) + # Should succeed even with text output + assert result.exit_code == 0, f"List text failed: {result.stdout}" + + +def test_joblog_get_nonexistent(runner, fess_service): + """ + Test getting a non-existent job log entry returns an error. + """ + result = runner.invoke( + joblog_app, + ["get", "nonexistent-id-12345"] + ) + assert result.exit_code != 0 + assert "failed to retrieve joblog" in result.stdout.lower() + + +def test_joblog_get_nonexistent_json_output(runner, fess_service): + """ + Test getting a non-existent job log entry with JSON output. + """ + result = runner.invoke( + joblog_app, + ["get", "nonexistent-id-12345", "--output", "json"] + ) + # JSON output always returns, but status should indicate failure + get_resp = json.loads(result.stdout) + assert get_resp.get("response", {}).get("status") != 0 + + +def test_joblog_delete_nonexistent(runner, fess_service): + """ + Test deleting a non-existent job log entry returns an error. + """ + result = runner.invoke( + joblog_app, + ["delete", "nonexistent-id-12345"] + ) + assert result.exit_code != 0 + assert "failed to delete joblog" in result.stdout.lower() + + +def test_joblog_delete_nonexistent_json_output(runner, fess_service): + """ + Test deleting a non-existent job log entry with JSON output. + """ + result = runner.invoke( + joblog_app, + ["delete", "nonexistent-id-12345", "--output", "json"] + ) + # JSON output always returns, but status should indicate failure + del_resp = json.loads(result.stdout) + assert del_resp.get("response", {}).get("status") != 0 diff --git a/tests/commands/test_scheduler.py b/tests/commands/test_scheduler.py index 7b9f005..0590fb3 100644 --- a/tests/commands/test_scheduler.py +++ b/tests/commands/test_scheduler.py @@ -93,3 +93,138 @@ def test_scheduler_crud_flow(runner, fess_service): ) assert result.exit_code != 0 assert "failed to retrieve scheduler" in result.stdout.lower() + + +def test_scheduler_start_stop_json_output(runner, fess_service): + """ + Tests the start and stop commands return valid JSON responses. + Note: Start/stop may fail for schedulers without valid script data, + so we verify the command returns a valid response structure. + """ + # 1) Create a new scheduler for testing start/stop + unique_name = f"schedule-startstop-{uuid.uuid4().hex[:8]}" + target = "all" + script_type = "groovy" + cron = "0 0 * * *" # Daily at midnight + result = runner.invoke( + scheduler_app, + ["create", "--name", unique_name, "--target", target, "--script-type", script_type, "--cron-expression", cron, "--output", "json"] + ) + assert result.exit_code == 0, f"Create failed: {result.stdout}" + create_resp = json.loads(result.stdout) + assert create_resp.get("response", {}).get("status") == 0 + scheduler_id = create_resp["response"].get("id") + assert scheduler_id, "No scheduler ID returned on create" + + try: + # 2) Start the scheduler - verify returns valid JSON with response structure + result = runner.invoke( + scheduler_app, + ["start", scheduler_id, "--output", "json"] + ) + # Command should return JSON regardless of success/failure + start_resp = json.loads(result.stdout) + assert "response" in start_resp + assert "status" in start_resp["response"] + + # 3) Stop the scheduler - verify returns valid JSON with response structure + result = runner.invoke( + scheduler_app, + ["stop", scheduler_id, "--output", "json"] + ) + stop_resp = json.loads(result.stdout) + assert "response" in stop_resp + assert "status" in stop_resp["response"] + + finally: + # 4) Clean up - delete the scheduler + runner.invoke( + scheduler_app, + ["delete", scheduler_id, "--output", "json"] + ) + + +def test_scheduler_start_text_output(runner, fess_service): + """ + Tests the start command with text output format. + Verifies the command produces output (success or failure message). + """ + # Create a scheduler + unique_name = f"schedule-text-{uuid.uuid4().hex[:8]}" + result = runner.invoke( + scheduler_app, + ["create", "--name", unique_name, "--target", "all", "--script-type", "groovy", "--cron-expression", "0 0 * * *", "--output", "json"] + ) + assert result.exit_code == 0 + scheduler_id = json.loads(result.stdout)["response"]["id"] + + try: + # Start with text output - should produce some output + result = runner.invoke( + scheduler_app, + ["start", scheduler_id, "--output", "text"] + ) + # Verify there is output (either success or failure message) + assert len(result.stdout) > 0 + assert "scheduler" in result.stdout.lower() + + # Stop with text output - should produce some output + result = runner.invoke( + scheduler_app, + ["stop", scheduler_id, "--output", "text"] + ) + assert len(result.stdout) > 0 + assert "scheduler" in result.stdout.lower() + + finally: + runner.invoke(scheduler_app, ["delete", scheduler_id]) + + +def test_scheduler_start_yaml_output(runner, fess_service): + """ + Tests the start command with YAML output format. + """ + # Create a scheduler + unique_name = f"schedule-yaml-{uuid.uuid4().hex[:8]}" + result = runner.invoke( + scheduler_app, + ["create", "--name", unique_name, "--target", "all", "--script-type", "groovy", "--cron-expression", "0 0 * * *", "--output", "json"] + ) + assert result.exit_code == 0 + scheduler_id = json.loads(result.stdout)["response"]["id"] + + try: + # Start with YAML output - verify YAML structure + result = runner.invoke( + scheduler_app, + ["start", scheduler_id, "--output", "yaml"] + ) + # YAML output should contain response key + assert "response:" in result.stdout + + finally: + runner.invoke(scheduler_app, ["delete", scheduler_id]) + + +def test_scheduler_start_nonexistent(runner, fess_service): + """ + Tests starting a non-existent scheduler returns an error. + """ + result = runner.invoke( + scheduler_app, + ["start", "nonexistent-scheduler-id"] + ) + assert result.exit_code != 0 + assert "failed to start scheduler" in result.stdout.lower() + + +def test_scheduler_stop_nonexistent(runner, fess_service): + """ + Tests stopping a non-existent scheduler returns an error. + """ + result = runner.invoke( + scheduler_app, + ["stop", "nonexistent-scheduler-id"] + ) + assert result.exit_code != 0 + assert "failed to stop scheduler" in result.stdout.lower() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..56c9ecf --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,212 @@ +""" +Unit tests for fessctl.cli module (ping command). +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.cli import app +from fessctl.api.client import FessAPIClientError + + +@pytest.fixture +def runner(): + """Provides a CliRunner instance for invoking commands.""" + return CliRunner() + + +class TestPingCommand: + """Tests for the ping command.""" + + @patch("fessctl.cli.FessAPIClient") + def test_ping_healthy_server_text_output(self, mock_client_class, runner): + """Test ping with healthy server returns green status in text output.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": False} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 0 + assert "healthy" in result.stdout.lower() + assert "green" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_healthy_server_json_output(self, mock_client_class, runner): + """Test ping with healthy server returns JSON output.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": False} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping", "--output", "json"]) + + assert result.exit_code == 0 + response = json.loads(result.stdout) + assert response["data"]["status"] == "green" + + @patch("fessctl.cli.FessAPIClient") + def test_ping_healthy_server_yaml_output(self, mock_client_class, runner): + """Test ping with healthy server returns YAML output.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": False} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping", "--output", "yaml"]) + + assert result.exit_code == 0 + assert "status: green" in result.stdout + + @patch("fessctl.cli.FessAPIClient") + def test_ping_yellow_status(self, mock_client_class, runner): + """Test ping with yellow status shows warning.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "yellow", "timed_out": False} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 0 + assert "yellow" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_red_status(self, mock_client_class, runner): + """Test ping with red status returns error.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "red", "timed_out": False}, + "response": {"message": "Cluster is unhealthy"} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + assert "red" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_timed_out(self, mock_client_class, runner): + """Test ping with timed_out=True returns error.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": True}, + "response": {"message": "Request timed out"} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + assert "timed_out" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_unknown_status(self, mock_client_class, runner): + """Test ping with unknown status returns error.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "unknown", "timed_out": True}, + "response": {"message": ""} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + + @patch("fessctl.cli.FessAPIClient") + def test_ping_connection_error(self, mock_client_class, runner): + """Test ping with connection error.""" + mock_client = Mock() + mock_client.ping.side_effect = FessAPIClientError( + status_code=-1, content="Connection refused" + ) + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + assert "Connection refused" in result.stdout or "error" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_generic_exception(self, mock_client_class, runner): + """Test ping with generic exception.""" + mock_client = Mock() + mock_client.ping.side_effect = Exception("Unexpected error") + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + assert "Unexpected error" in result.stdout + + +class TestAppStructure: + """Tests for the CLI app structure.""" + + def test_app_has_ping_command(self, runner): + """Test that the app has a ping command.""" + result = runner.invoke(app, ["--help"]) + + assert "ping" in result.stdout + + def test_app_no_args_shows_help(self, runner): + """Test that running app with no args shows help.""" + result = runner.invoke(app, []) + + # Should show help due to no_args_is_help=True + assert "Usage:" in result.stdout or "Commands:" in result.stdout + + def test_app_has_subcommands(self, runner): + """Test that the app has expected subcommands.""" + result = runner.invoke(app, ["--help"]) + + # Check for some expected subcommands + expected_commands = [ + "accesstoken", "badword", "group", "role", "user", + "webconfig", "fileconfig", "scheduler" + ] + for cmd in expected_commands: + assert cmd in result.stdout, f"Expected command '{cmd}' not found in help output" + + +class TestPingOutputFormats: + """Additional tests for ping output formats.""" + + @patch("fessctl.cli.FessAPIClient") + def test_ping_json_output_is_valid_json(self, mock_client_class, runner): + """Test that JSON output is valid JSON.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": False, "number_of_nodes": 3} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping", "-o", "json"]) + + # Should not raise + data = json.loads(result.stdout) + assert "data" in data + + @patch("fessctl.cli.FessAPIClient") + def test_ping_short_output_flag(self, mock_client_class, runner): + """Test that -o short flag works for output.""" + mock_client = Mock() + mock_client.ping.return_value = { + "data": {"status": "green", "timed_out": False} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping", "-o", "json"]) + + assert result.exit_code == 0 + # Should be JSON + json.loads(result.stdout) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..57c9690 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,475 @@ +""" +Unit tests for fessctl.api.client module. +""" +import json +from unittest.mock import Mock, patch, MagicMock + +import httpx +import pytest + +from fessctl.api.client import FessAPIClient, FessAPIClientError, Action +from fessctl.config.settings import Settings + + +@pytest.fixture +def mock_settings(): + """Create a mock Settings object.""" + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = "15.4.0" + return settings + + +@pytest.fixture +def mock_settings_v14(): + """Create a mock Settings object for Fess 14.x.""" + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = "14.19.2" + return settings + + +@pytest.fixture +def client(mock_settings): + """Create a FessAPIClient instance with mocked settings.""" + return FessAPIClient(mock_settings) + + +@pytest.fixture +def client_v14(mock_settings_v14): + """Create a FessAPIClient instance for Fess 14.x.""" + return FessAPIClient(mock_settings_v14) + + +class TestFessAPIClientInit: + """Tests for FessAPIClient initialization.""" + + def test_init_with_valid_settings(self, mock_settings): + """Test client initialization with valid settings.""" + client = FessAPIClient(mock_settings) + + assert client.base_url == "http://localhost:8080" + assert client.timeout == 5.0 + assert client._major_version == 15 + assert client._minor_version == 4 + + def test_init_with_custom_timeout(self, mock_settings): + """Test client initialization with custom timeout.""" + client = FessAPIClient(mock_settings, timeout=10.0) + + assert client.timeout == 10.0 + + def test_init_sets_admin_headers(self, mock_settings): + """Test that admin headers are properly set.""" + client = FessAPIClient(mock_settings) + + assert client.admin_api_headers["Authorization"] == "Bearer test-token" + assert client.admin_api_headers["Content-Type"] == "application/json" + + def test_init_sets_search_headers(self, mock_settings): + """Test that search headers are properly set (no auth).""" + client = FessAPIClient(mock_settings) + + assert "Authorization" not in client.search_api_headers + assert client.search_api_headers["Content-Type"] == "application/json" + + +class TestParseVersion: + """Tests for the _parse_version method.""" + + def test_parse_version_standard_format(self, mock_settings): + """Test parsing standard version format.""" + mock_settings.fess_version = "15.4.0" + client = FessAPIClient(mock_settings) + + assert client._major_version == 15 + assert client._minor_version == 4 + + def test_parse_version_v14(self, mock_settings): + """Test parsing version 14.x.""" + mock_settings.fess_version = "14.19.2" + client = FessAPIClient(mock_settings) + + assert client._major_version == 14 + assert client._minor_version == 19 + + def test_parse_version_with_snapshot(self, mock_settings): + """Test parsing version with SNAPSHOT suffix.""" + mock_settings.fess_version = "16.0.0-SNAPSHOT" + client = FessAPIClient(mock_settings) + + assert client._major_version == 16 + assert client._minor_version == 0 + + def test_parse_version_invalid_format_raises(self, mock_settings): + """Test that invalid version format raises ValueError.""" + mock_settings.fess_version = "invalid" + + with pytest.raises(ValueError) as exc_info: + FessAPIClient(mock_settings) + + assert "Invalid version format" in str(exc_info.value) + + def test_parse_version_empty_string_raises(self, mock_settings): + """Test that empty version string raises ValueError.""" + mock_settings.fess_version = "" + + with pytest.raises(ValueError): + FessAPIClient(mock_settings) + + def test_parse_version_non_numeric_raises(self, mock_settings): + """Test that non-numeric version raises ValueError.""" + mock_settings.fess_version = "abc.def.ghi" + + with pytest.raises(ValueError): + FessAPIClient(mock_settings) + + +class TestSendRequestHttpMethods: + """Tests for HTTP method selection based on action and version.""" + + @patch("httpx.post") + def test_create_fess15_uses_post(self, mock_post, client): + """Test that CREATE action uses POST for Fess 15.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_post.return_value = mock_response + + client.send_request(Action.CREATE, "http://test/api", json_data={"test": "data"}) + + mock_post.assert_called_once() + + @patch("httpx.put") + def test_create_fess14_uses_put(self, mock_put, client_v14): + """Test that CREATE action uses PUT for Fess 14.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client_v14.send_request(Action.CREATE, "http://test/api", json_data={"test": "data"}) + + mock_put.assert_called_once() + + @patch("httpx.put") + def test_edit_fess15_uses_put(self, mock_put, client): + """Test that EDIT action uses PUT for Fess 15.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client.send_request(Action.EDIT, "http://test/api", json_data={"test": "data"}) + + mock_put.assert_called_once() + + @patch("httpx.post") + def test_edit_fess14_uses_post(self, mock_post, client_v14): + """Test that EDIT action uses POST for Fess 14.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_post.return_value = mock_response + + client_v14.send_request(Action.EDIT, "http://test/api", json_data={"test": "data"}) + + mock_post.assert_called_once() + + @patch("httpx.delete") + def test_delete_uses_delete(self, mock_delete, client): + """Test that DELETE action uses DELETE method.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_delete.return_value = mock_response + + client.send_request(Action.DELETE, "http://test/api") + + mock_delete.assert_called_once() + + @patch("httpx.get") + def test_list_uses_get(self, mock_get, 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 + + client.send_request(Action.LIST, "http://test/api") + + mock_get.assert_called_once() + + @patch("httpx.get") + def test_get_uses_get(self, mock_get, client): + """Test that GET action uses GET method.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_get.return_value = mock_response + + client.send_request(Action.GET, "http://test/api") + + mock_get.assert_called_once() + + @patch("httpx.put") + def test_start_fess15_uses_put(self, mock_put, client): + """Test that START action uses PUT for Fess 15.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client.send_request(Action.START, "http://test/api") + + mock_put.assert_called_once() + + @patch("httpx.post") + def test_start_fess14_uses_post(self, mock_post, client_v14): + """Test that START action uses POST for Fess 14.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_post.return_value = mock_response + + client_v14.send_request(Action.START, "http://test/api") + + mock_post.assert_called_once() + + @patch("httpx.put") + def test_stop_fess15_uses_put(self, mock_put, client): + """Test that STOP action uses PUT for Fess 15.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client.send_request(Action.STOP, "http://test/api") + + mock_put.assert_called_once() + + @patch("httpx.post") + def test_stop_fess14_uses_post(self, mock_post, client_v14): + """Test that STOP action uses POST for Fess 14.x.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_post.return_value = mock_response + + client_v14.send_request(Action.STOP, "http://test/api") + + mock_post.assert_called_once() + + +class TestSendRequestErrorHandling: + """Tests for error handling in send_request.""" + + @patch("httpx.get") + def test_network_error_raises_client_error(self, mock_get, client): + """Test that network errors raise FessAPIClientError.""" + mock_get.side_effect = httpx.RequestError("Connection refused") + + with pytest.raises(FessAPIClientError) as exc_info: + client.send_request(Action.GET, "http://test/api") + + assert exc_info.value.status_code == -1 + assert "Network error" in exc_info.value.content + + @patch("httpx.get") + def test_timeout_error_raises_client_error(self, mock_get, client): + """Test that timeout errors raise FessAPIClientError.""" + mock_get.side_effect = httpx.TimeoutException("Request timed out") + + with pytest.raises(FessAPIClientError) as exc_info: + client.send_request(Action.GET, "http://test/api") + + assert exc_info.value.status_code == -1 + + @patch("httpx.get") + def test_invalid_json_response_raises_client_error(self, mock_get, client): + """Test that invalid JSON response raises FessAPIClientError.""" + mock_response = Mock() + mock_response.json.side_effect = json.decoder.JSONDecodeError("Invalid", "", 0) + mock_response.status_code = 200 + mock_response.text = "Not JSON" + mock_get.return_value = mock_response + + with pytest.raises(FessAPIClientError) as exc_info: + client.send_request(Action.GET, "http://test/api") + + assert "Invalid JSON response" in exc_info.value.content + + @patch("httpx.get") + def test_returns_json_response(self, mock_get, client): + """Test that valid JSON response is returned.""" + expected_data = {"response": {"status": 0, "data": "test"}} + mock_response = Mock() + mock_response.json.return_value = expected_data + mock_get.return_value = mock_response + + result = client.send_request(Action.GET, "http://test/api") + + assert result == expected_data + + +class TestSendRequestHeaders: + """Tests for header handling in send_request.""" + + @patch("httpx.get") + def test_admin_request_uses_admin_headers(self, mock_get, client): + """Test that admin requests use admin headers.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_get.return_value = mock_response + + client.send_request(Action.GET, "http://test/api", is_admin=True) + + call_kwargs = mock_get.call_args[1] + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token" + + @patch("httpx.get") + def test_non_admin_request_uses_search_headers(self, mock_get, client): + """Test that non-admin requests use search headers (no auth).""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_get.return_value = mock_response + + client.send_request(Action.GET, "http://test/api", is_admin=False) + + call_kwargs = mock_get.call_args[1] + assert "Authorization" not in call_kwargs["headers"] + + +class TestFessAPIClientError: + """Tests for FessAPIClientError exception.""" + + def test_error_message_format(self): + """Test error message formatting.""" + error = FessAPIClientError(status_code=404, content="Not found") + + assert "HTTP 404 Error" in str(error) + assert "Not found" in str(error) + + def test_error_attributes(self): + """Test error attributes are accessible.""" + error = FessAPIClientError(status_code=500, content="Server error") + + assert error.status_code == 500 + assert error.content == "Server error" + + +class TestPingMethod: + """Tests for the ping method.""" + + @patch("httpx.get") + def test_ping_calls_health_endpoint(self, mock_get, client): + """Test that ping calls the correct health endpoint.""" + mock_response = Mock() + mock_response.json.return_value = {"data": {"status": "green"}} + mock_get.return_value = mock_response + + client.ping() + + mock_get.assert_called_once() + call_url = mock_get.call_args[0][0] + assert "/api/v1/health" in call_url + + @patch("httpx.get") + def test_ping_uses_search_headers(self, mock_get, client): + """Test that ping uses search headers (no auth).""" + mock_response = Mock() + mock_response.json.return_value = {"data": {"status": "green"}} + mock_get.return_value = mock_response + + client.ping() + + call_kwargs = mock_get.call_args[1] + assert "Authorization" not in call_kwargs["headers"] + + +class TestRoleAPIs: + """Tests for Role API methods.""" + + @patch("httpx.post") + def test_create_role_with_name(self, mock_post, client): + """Test creating a role with just a name.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0, "id": "role-123"}} + mock_post.return_value = mock_response + + result = client.create_role("admin") + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + assert call_kwargs["json"]["name"] == "admin" + assert call_kwargs["json"]["crud_mode"] == 1 + + @patch("httpx.post") + def test_create_role_with_attributes(self, mock_post, client): + """Test creating a role with attributes.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0, "id": "role-123"}} + mock_post.return_value = mock_response + + client.create_role("admin", attributes={"key": "value"}) + + call_kwargs = mock_post.call_args[1] + assert call_kwargs["json"]["attributes"] == {"key": "value"} + + @patch("httpx.delete") + def test_delete_role(self, mock_delete, client): + """Test deleting a role.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_delete.return_value = mock_response + + client.delete_role("role-123") + + call_url = mock_delete.call_args[0][0] + assert "role-123" in call_url + + @patch("httpx.get") + def test_get_role(self, mock_get, client): + """Test getting a role by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0, "setting": {"id": "role-123"}}} + mock_get.return_value = mock_response + + client.get_role("role-123") + + 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): + """Test listing roles with pagination.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0, "settings": []}} + mock_get.return_value = mock_response + + client.list_roles(page=2, size=50) + + call_kwargs = mock_get.call_args[1] + assert call_kwargs["params"]["page"] == 2 + assert call_kwargs["params"]["size"] == 50 + + +class TestSchedulerAPIs: + """Tests for Scheduler API methods.""" + + @patch("httpx.put") + def test_start_scheduler(self, mock_put, client): + """Test starting a scheduler.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client.start_scheduler("scheduler-123") + + call_url = mock_put.call_args[0][0] + assert "scheduler-123" in call_url + assert "/start" in call_url + + @patch("httpx.put") + def test_stop_scheduler(self, mock_put, client): + """Test stopping a scheduler.""" + mock_response = Mock() + mock_response.json.return_value = {"response": {"status": 0}} + mock_put.return_value = mock_response + + client.stop_scheduler("scheduler-123") + + call_url = mock_put.call_args[0][0] + assert "scheduler-123" in call_url + assert "/stop" in call_url diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..baa7939 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,120 @@ +""" +Unit tests for fessctl.config.settings module. +""" +import os +import pytest + +from fessctl.config.settings import Settings + + +class TestSettings: + """Tests for the Settings dataclass.""" + + def test_default_values(self, monkeypatch): + """Test that default values are used when environment variables are not set.""" + # Clear any existing environment variables + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + assert settings.fess_endpoint == "http://localhost:8080" + assert settings.access_token is None + assert settings.fess_version == "15.4.0" + + def test_endpoint_from_environment(self, monkeypatch): + """Test that FESS_ENDPOINT environment variable is used.""" + monkeypatch.setenv("FESS_ENDPOINT", "http://custom-fess:9200") + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + assert settings.fess_endpoint == "http://custom-fess:9200" + + def test_access_token_from_environment(self, monkeypatch): + """Test that FESS_ACCESS_TOKEN environment variable is used.""" + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.setenv("FESS_ACCESS_TOKEN", "my-secret-token") + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + assert settings.access_token == "my-secret-token" + + def test_version_from_environment(self, monkeypatch): + """Test that FESS_VERSION environment variable is used.""" + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.setenv("FESS_VERSION", "14.19.2") + + settings = Settings() + + assert settings.fess_version == "14.19.2" + + def test_all_values_from_environment(self, monkeypatch): + """Test that all environment variables are used together.""" + monkeypatch.setenv("FESS_ENDPOINT", "http://production-fess:8080") + monkeypatch.setenv("FESS_ACCESS_TOKEN", "production-token") + monkeypatch.setenv("FESS_VERSION", "15.3.2") + + settings = Settings() + + assert settings.fess_endpoint == "http://production-fess:8080" + assert settings.access_token == "production-token" + assert settings.fess_version == "15.3.2" + + def test_settings_is_frozen(self, monkeypatch): + """Test that Settings is immutable (frozen dataclass).""" + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + with pytest.raises(AttributeError): + settings.fess_endpoint = "http://new-endpoint:8080" + + def test_empty_access_token_string(self, monkeypatch): + """Test that empty string for access token is treated as empty string, not None.""" + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.setenv("FESS_ACCESS_TOKEN", "") + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + # Empty string is still a valid value from environment + assert settings.access_token == "" + + def test_endpoint_with_trailing_slash(self, monkeypatch): + """Test endpoint with trailing slash is preserved as-is.""" + monkeypatch.setenv("FESS_ENDPOINT", "http://fess:8080/") + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + # The trailing slash should be preserved + assert settings.fess_endpoint == "http://fess:8080/" + + def test_endpoint_https(self, monkeypatch): + """Test that HTTPS endpoints work correctly.""" + monkeypatch.setenv("FESS_ENDPOINT", "https://secure-fess.example.com:443") + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("FESS_VERSION", raising=False) + + settings = Settings() + + assert settings.fess_endpoint == "https://secure-fess.example.com:443" + + def test_version_formats(self, monkeypatch): + """Test various version format strings.""" + test_versions = ["14.0.0", "15.3.2", "15.4.0", "16.0.0-SNAPSHOT"] + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + + for version in test_versions: + monkeypatch.setenv("FESS_VERSION", version) + settings = Settings() + assert settings.fess_version == version diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..b4f1f1a --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,143 @@ +""" +Unit tests for fessctl.utils module. +""" +import pytest + +from fessctl.utils import to_utc_iso8601, encode_to_urlsafe_base64 + + +class TestToUtcIso8601: + """Tests for the to_utc_iso8601 function.""" + + def test_with_valid_epoch_millis(self): + """Test conversion of valid epoch milliseconds.""" + # 2024-01-15 12:30:45 UTC = 1705321845000 ms + epoch_millis = 1705321845000 + result = to_utc_iso8601(epoch_millis) + assert result == "2024-01-15T12:30:45Z" + + def test_with_zero_epoch(self): + """Test conversion of epoch 0 (1970-01-01T00:00:00Z).""" + epoch_millis = 0 + result = to_utc_iso8601(epoch_millis) + assert result == "1970-01-01T00:00:00Z" + + def test_with_none_returns_dash(self): + """Test that None input returns '-'.""" + result = to_utc_iso8601(None) + assert result == "-" + + def test_with_string_epoch(self): + """Test conversion of epoch as string (should be converted to int).""" + epoch_millis_str = "1705321845000" + result = to_utc_iso8601(epoch_millis_str) + assert result == "2024-01-15T12:30:45Z" + + def test_with_integer_epoch(self): + """Test conversion with integer type.""" + # 2000-01-01T00:00:00Z = 946684800000 ms + epoch_millis = 946684800000 + result = to_utc_iso8601(epoch_millis) + assert result == "2000-01-01T00:00:00Z" + + def test_iso8601_format_ends_with_z(self): + """Test that output ends with Z indicating UTC timezone.""" + result = to_utc_iso8601(1705321845000) + assert result.endswith("Z") + + def test_iso8601_format_has_t_separator(self): + """Test that output has T separator between date and time.""" + result = to_utc_iso8601(1705321845000) + assert "T" in result + + def test_with_large_epoch(self): + """Test with a large epoch value (future date).""" + # Use a known epoch and verify format is correct + # 2539424200000 ms converts to 2050-06-21T11:36:40Z + epoch_millis = 2539424200000 + result = to_utc_iso8601(epoch_millis) + assert result == "2050-06-21T11:36:40Z" + + +class TestEncodeToUrlsafeBase64: + """Tests for the encode_to_urlsafe_base64 function.""" + + def test_basic_string(self): + """Test encoding of a basic ASCII string.""" + text = "hello" + result = encode_to_urlsafe_base64(text) + assert result == "aGVsbG8=" + + def test_empty_string(self): + """Test encoding of an empty string.""" + text = "" + result = encode_to_urlsafe_base64(text) + assert result == "" + + def test_string_with_special_chars(self): + """Test encoding of a string with special characters.""" + text = "hello+world/test" + result = encode_to_urlsafe_base64(text) + # URL-safe base64 should not contain + or / + assert "+" not in result or result.count("+") == 0 + # Verify it can be decoded back + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text + + def test_unicode_string(self): + """Test encoding of a Unicode string.""" + text = "こんにちは" # Japanese "hello" + result = encode_to_urlsafe_base64(text) + # Verify it can be decoded back + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text + + def test_string_with_spaces(self): + """Test encoding of a string with spaces.""" + text = "hello world" + result = encode_to_urlsafe_base64(text) + assert result == "aGVsbG8gd29ybGQ=" + + def test_urlsafe_no_standard_base64_chars(self): + """Test that URL-safe encoding uses - and _ instead of + and /.""" + # A string that would produce + and / in standard base64 + text = "subjects?_d" + result = encode_to_urlsafe_base64(text) + # URL-safe base64 uses - instead of + and _ instead of / + # Standard base64 would give: c3ViamVjdHM/X2Q= + # URL-safe base64 should not have standard unsafe chars in the middle + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text + + def test_numeric_string(self): + """Test encoding of a numeric string.""" + text = "12345" + result = encode_to_urlsafe_base64(text) + assert result == "MTIzNDU=" + + def test_long_string(self): + """Test encoding of a longer string.""" + text = "The quick brown fox jumps over the lazy dog" + result = encode_to_urlsafe_base64(text) + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text + + def test_string_with_newlines(self): + """Test encoding of a string with newlines.""" + text = "line1\nline2\nline3" + result = encode_to_urlsafe_base64(text) + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text + + def test_email_address(self): + """Test encoding of an email address (common use case).""" + text = "user@example.com" + result = encode_to_urlsafe_base64(text) + import base64 + decoded = base64.urlsafe_b64decode(result).decode('utf-8') + assert decoded == text