test_docs_cmd.py
1 """ 2 Unit tests for cli/commands/docs_cmd.py 3 4 Tests the docs command including: 5 - Starting documentation server with default port 6 - Custom port configuration 7 - Browser opening behavior 8 - Error handling (missing docs directory) 9 - HTTP request handler path rewriting 10 - 404 redirect behavior 11 - Keyboard interrupt handling 12 """ 13 14 from unittest.mock import MagicMock, Mock, patch 15 import pytest 16 from click.testing import CliRunner 17 18 from cli.commands.docs_cmd import docs, DocsHttpRequestHandler 19 20 21 @pytest.fixture 22 def runner(): 23 """Create a Click CLI runner for testing""" 24 return CliRunner() 25 26 27 @pytest.fixture 28 def mock_docs_dir(tmp_path): 29 """Create a temporary docs directory""" 30 docs_dir = tmp_path / "docs" 31 docs_dir.mkdir() 32 (docs_dir / "index.html").write_text("<html>Test</html>") 33 return docs_dir 34 35 36 class TestDocsHttpRequestHandler: 37 """Tests for the DocsHttpRequestHandler class""" 38 39 def test_path_rewriting_with_solace_prefix(self): 40 """Test that paths starting with /solace-agent-mesh are rewritten""" 41 # Create a mock request with proper bytes 42 mock_request = MagicMock() 43 mock_request.makefile.return_value = MagicMock() 44 45 # We need to test the path rewriting logic directly 46 # Create handler without triggering __init__ fully 47 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 48 handler.path = "/solace-agent-mesh/docs/getting-started/" 49 50 # Mock the parent do_GET to avoid actual HTTP handling 51 with patch.object(DocsHttpRequestHandler.__bases__[0], 'do_GET'): 52 handler.do_GET() 53 54 assert handler.path == "/docs/getting-started/" 55 56 def test_path_rewriting_root_path(self): 57 """Test that /solace-agent-mesh alone becomes /""" 58 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 59 handler.path = "/solace-agent-mesh" 60 61 with patch.object(DocsHttpRequestHandler.__bases__[0], 'do_GET'): 62 handler.do_GET() 63 64 assert handler.path == "/" 65 66 def test_path_without_prefix_unchanged(self): 67 """Test that paths without /solace-agent-mesh prefix are unchanged""" 68 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 69 handler.path = "/other/path" 70 71 with patch.object(DocsHttpRequestHandler.__bases__[0], 'do_GET'): 72 handler.do_GET() 73 74 assert handler.path == "/other/path" 75 76 def test_404_redirect(self): 77 """Test that 404 errors redirect to introduction page""" 78 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 79 80 # Mock the response methods 81 handler.send_response = Mock() 82 handler.send_header = Mock() 83 handler.end_headers = Mock() 84 85 handler.send_error(404) 86 87 handler.send_response.assert_called_once_with(302) 88 handler.send_header.assert_called_once_with( 89 'Location', 90 '/solace-agent-mesh/docs/documentation/getting-started/introduction/' 91 ) 92 handler.end_headers.assert_called_once() 93 94 def test_non_404_error_uses_parent(self): 95 """Test that non-404 errors use parent error handling""" 96 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 97 98 with patch.object(DocsHttpRequestHandler.__bases__[0], 'send_error') as mock_parent: 99 handler.send_error(500, "Internal Server Error") 100 mock_parent.assert_called_once_with(500, "Internal Server Error") 101 102 103 class TestDocsCommand: 104 """Tests for the docs CLI command""" 105 106 def test_docs_command_with_prod_docs(self, runner, tmp_path, mocker): 107 """Test docs command when production docs directory exists""" 108 # Create mock prod docs directory 109 prod_docs = tmp_path / "assets" / "docs" 110 prod_docs.mkdir(parents=True) 111 (prod_docs / "index.html").write_text("<html>Prod Docs</html>") 112 113 # Mock get_cli_root_dir to return our tmp_path 114 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path)) 115 116 # Mock webbrowser and TCPServer 117 mock_browser = mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 118 mock_server = MagicMock() 119 mock_server.__enter__ = Mock(return_value=mock_server) 120 mock_server.__exit__ = Mock(return_value=False) 121 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 122 123 mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 124 125 result = runner.invoke(docs) 126 127 assert result.exit_code == 0 128 assert "Starting documentation server" in result.output 129 mock_browser.assert_called_once() 130 assert "http://localhost:8585" in mock_browser.call_args[0][0] 131 132 def test_docs_command_with_dev_docs(self, runner, tmp_path, mocker): 133 """Test docs command when only dev docs directory exists""" 134 # Create mock dev docs directory 135 dev_docs = tmp_path / "docs" / "build" 136 dev_docs.mkdir(parents=True) 137 (dev_docs / "index.html").write_text("<html>Dev Docs</html>") 138 139 # Mock paths - prod doesn't exist, dev does 140 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path / "nonexistent")) 141 mocker.patch("cli.commands.docs_cmd.os.path.dirname", return_value=str(tmp_path)) 142 143 def mock_exists(path): 144 return "docs/build" in str(path) 145 146 mocker.patch("cli.commands.docs_cmd.os.path.exists", side_effect=mock_exists) 147 148 # Mock webbrowser and TCPServer 149 mock_browser = mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 150 mock_server = MagicMock() 151 mock_server.__enter__ = Mock(return_value=mock_server) 152 mock_server.__exit__ = Mock(return_value=False) 153 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 154 155 mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 156 157 result = runner.invoke(docs) 158 159 assert result.exit_code == 0 160 assert "Serving development documentation" in result.output 161 mock_browser.assert_called_once() 162 163 def test_docs_command_custom_port(self, runner, tmp_path, mocker): 164 """Test docs command with custom port""" 165 # Create mock docs directory 166 prod_docs = tmp_path / "assets" / "docs" 167 prod_docs.mkdir(parents=True) 168 169 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path)) 170 171 # Mock webbrowser and TCPServer 172 mock_browser = mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 173 mock_server = MagicMock() 174 mock_server.__enter__ = Mock(return_value=mock_server) 175 mock_server.__exit__ = Mock(return_value=False) 176 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 177 178 mock_tcp_server = mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 179 180 result = runner.invoke(docs, ["--port", "9000"]) 181 182 assert result.exit_code == 0 183 assert "http://localhost:9000" in result.output 184 mock_browser.assert_called_once() 185 assert "http://localhost:9000" in mock_browser.call_args[0][0] 186 187 # Verify TCPServer was called with correct port 188 assert mock_tcp_server.call_args[0][0] == ("", 9000) 189 190 def test_docs_command_missing_docs_directory(self, runner, tmp_path, mocker): 191 """Test docs command when no docs directory exists""" 192 # Mock paths to non-existent directories 193 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path / "nonexistent")) 194 mocker.patch("cli.commands.docs_cmd.os.path.dirname", return_value=str(tmp_path / "nonexistent")) 195 mocker.patch("cli.commands.docs_cmd.os.path.exists", return_value=False) 196 197 # Mock error_exit to raise SystemExit 198 mock_error_exit = mocker.patch("cli.commands.docs_cmd.error_exit", side_effect=SystemExit(1)) 199 200 result = runner.invoke(docs) 201 202 assert result.exit_code == 1 203 mock_error_exit.assert_called_once() 204 assert "Documentation directory not found" in mock_error_exit.call_args[0][0] 205 206 def test_docs_command_keyboard_interrupt(self, runner, tmp_path, mocker): 207 """Test docs command handles keyboard interrupt gracefully""" 208 # Create mock docs directory 209 prod_docs = tmp_path / "assets" / "docs" 210 prod_docs.mkdir(parents=True) 211 212 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path)) 213 mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 214 215 # Mock TCPServer to raise KeyboardInterrupt 216 mock_server = MagicMock() 217 mock_server.__enter__ = Mock(return_value=mock_server) 218 mock_server.__exit__ = Mock(return_value=False) 219 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 220 221 mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 222 223 result = runner.invoke(docs) 224 225 # Should exit cleanly on KeyboardInterrupt 226 assert result.exit_code == 0 227 228 def test_docs_command_port_option_validation(self, runner, tmp_path, mocker): 229 """Test docs command validates port as integer""" 230 result = runner.invoke(docs, ["--port", "invalid"]) 231 232 assert result.exit_code != 0 233 assert "Invalid value" in result.output or "Error" in result.output 234 235 def test_docs_command_short_port_option(self, runner, tmp_path, mocker): 236 """Test docs command with short -p option""" 237 # Create mock docs directory 238 prod_docs = tmp_path / "assets" / "docs" 239 prod_docs.mkdir(parents=True) 240 241 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path)) 242 243 mock_browser = mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 244 mock_server = MagicMock() 245 mock_server.__enter__ = Mock(return_value=mock_server) 246 mock_server.__exit__ = Mock(return_value=False) 247 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 248 249 mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 250 251 result = runner.invoke(docs, ["-p", "7777"]) 252 253 assert result.exit_code == 0 254 assert "http://localhost:7777" in result.output 255 256 def test_docs_handler_initialization(self, tmp_path): 257 """Test DocsHttpRequestHandler initialization with directory""" 258 docs_dir = str(tmp_path / "docs") 259 260 # Test that the handler class can be created with directory parameter 261 # We use __new__ to avoid triggering the socket handling in __init__ 262 handler = DocsHttpRequestHandler.__new__(DocsHttpRequestHandler) 263 264 # Verify the handler class was created (basic smoke test) 265 assert handler is not None 266 assert isinstance(handler, DocsHttpRequestHandler) 267 268 def test_docs_command_url_format(self, runner, tmp_path, mocker): 269 """Test that the URL format is correct""" 270 prod_docs = tmp_path / "assets" / "docs" 271 prod_docs.mkdir(parents=True) 272 273 mocker.patch("cli.commands.docs_cmd.get_cli_root_dir", return_value=str(tmp_path)) 274 275 mock_browser = mocker.patch("cli.commands.docs_cmd.webbrowser.open_new_tab") 276 mock_server = MagicMock() 277 mock_server.__enter__ = Mock(return_value=mock_server) 278 mock_server.__exit__ = Mock(return_value=False) 279 mock_server.serve_forever = Mock(side_effect=KeyboardInterrupt) 280 281 mocker.patch("cli.commands.docs_cmd.socketserver.TCPServer", return_value=mock_server) 282 283 result = runner.invoke(docs, ["--port", "8585"]) 284 285 # Verify the exact URL format 286 expected_url = "http://localhost:8585/solace-agent-mesh/docs/documentation/getting-started/introduction/" 287 mock_browser.assert_called_once_with(expected_url) 288 assert expected_url in result.output