From b8d1bfecbb422a2f1b526b83db5231ad46948b69 Mon Sep 17 00:00:00 2001 From: Alishahryar1 Date: Mon, 16 Feb 2026 15:58:16 -0800 Subject: [PATCH] Improved test coverage --- tests/test_app_lifespan_and_errors.py | 143 ++++++++++++++ tests/test_detection.py | 80 ++++++++ tests/test_discord_markdown.py | 201 +++++++++++++++++++ tests/test_discord_platform.py | 267 ++++++++++++++++++++++++++ tests/test_handler.py | 41 ++++ tests/test_nvidia_nim_request.py | 101 ++++++++++ tests/test_open_router.py | 149 ++++++++++++++ tests/test_optimization_handlers.py | 219 +++++++++++++++++++++ tests/test_process_registry.py | 44 +++++ tests/test_provider_rate_limit.py | 69 +++++++ tests/test_routes_optimizations.py | 14 ++ 11 files changed, 1328 insertions(+) create mode 100644 tests/test_detection.py create mode 100644 tests/test_discord_markdown.py create mode 100644 tests/test_nvidia_nim_request.py create mode 100644 tests/test_optimization_handlers.py diff --git a/tests/test_app_lifespan_and_errors.py b/tests/test_app_lifespan_and_errors.py index 1bfe650b..9019c759 100644 --- a/tests/test_app_lifespan_and_errors.py +++ b/tests/test_app_lifespan_and_errors.py @@ -210,3 +210,146 @@ def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path): fake_platform.stop.assert_awaited_once() cli_manager.stop_all.assert_awaited_once() cleanup_provider.assert_awaited_once() + + +def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog): + """Messaging import failure logs warning and continues without crash.""" + from api.app import create_app + + app = create_app() + + settings = SimpleNamespace( + messaging_platform="telegram", + telegram_bot_token="token", + allowed_telegram_user_id="123", + discord_bot_token=None, + allowed_discord_channels=None, + allowed_dir=str(tmp_path / "workspace"), + claude_workspace=str(tmp_path / "data"), + host="127.0.0.1", + port=8082, + max_cli_sessions=1, + log_file=str(tmp_path / "server.log"), + ) + + api_app_mod = importlib.import_module("api.app") + cleanup_provider = AsyncMock() + with ( + patch.object(api_app_mod, "get_settings", return_value=settings), + patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), + patch( + "messaging.factory.create_messaging_platform", + side_effect=ImportError("discord not installed"), + ), + ): + with TestClient(app): + pass + + assert getattr(app.state, "messaging_platform", None) is None + cleanup_provider.assert_awaited_once() + + +def test_app_lifespan_platform_start_exception_cleanup_still_runs(tmp_path): + """Exception during platform.start() logs error, cleanup still runs.""" + from api.app import create_app + + app = create_app() + + settings = SimpleNamespace( + messaging_platform="telegram", + telegram_bot_token="token", + allowed_telegram_user_id="123", + discord_bot_token=None, + allowed_discord_channels=None, + allowed_dir=str(tmp_path / "workspace"), + claude_workspace=str(tmp_path / "data"), + host="127.0.0.1", + port=8082, + max_cli_sessions=1, + log_file=str(tmp_path / "server.log"), + ) + + fake_platform = MagicMock() + fake_platform.name = "fake" + fake_platform.on_message = MagicMock() + fake_platform.start = AsyncMock(side_effect=RuntimeError("start failed")) + fake_platform.stop = AsyncMock() + + session_store = MagicMock() + session_store.get_all_trees.return_value = [] + session_store.get_node_mapping.return_value = {} + session_store.sync_from_tree_data = MagicMock() + + cli_manager = MagicMock() + cli_manager.stop_all = AsyncMock() + + api_app_mod = importlib.import_module("api.app") + cleanup_provider = AsyncMock() + with ( + patch.object(api_app_mod, "get_settings", return_value=settings), + patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), + patch( + "messaging.factory.create_messaging_platform", + return_value=fake_platform, + ), + patch("messaging.session.SessionStore", return_value=session_store), + patch("cli.manager.CLISessionManager", return_value=cli_manager), + ): + with TestClient(app): + pass + + cleanup_provider.assert_awaited_once() + + +def test_app_lifespan_flush_pending_save_exception_warning_only(tmp_path): + """Session store flush exception on shutdown is logged as warning, no crash.""" + from api.app import create_app + + app = create_app() + + settings = SimpleNamespace( + messaging_platform="telegram", + telegram_bot_token="token", + allowed_telegram_user_id="123", + discord_bot_token=None, + allowed_discord_channels=None, + allowed_dir=str(tmp_path / "workspace"), + claude_workspace=str(tmp_path / "data"), + host="127.0.0.1", + port=8082, + max_cli_sessions=1, + log_file=str(tmp_path / "server.log"), + ) + + fake_platform = MagicMock() + fake_platform.name = "fake" + fake_platform.on_message = MagicMock() + fake_platform.start = AsyncMock() + fake_platform.stop = AsyncMock() + + session_store = MagicMock() + session_store.get_all_trees.return_value = [] + session_store.get_node_mapping.return_value = {} + session_store.sync_from_tree_data = MagicMock() + session_store.flush_pending_save = MagicMock(side_effect=IOError("disk full")) + + cli_manager = MagicMock() + cli_manager.stop_all = AsyncMock() + + api_app_mod = importlib.import_module("api.app") + cleanup_provider = AsyncMock() + with ( + patch.object(api_app_mod, "get_settings", return_value=settings), + patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider), + patch( + "messaging.factory.create_messaging_platform", + return_value=fake_platform, + ), + patch("messaging.session.SessionStore", return_value=session_store), + patch("cli.manager.CLISessionManager", return_value=cli_manager), + ): + with TestClient(app): + pass + + session_store.flush_pending_save.assert_called_once() + cleanup_provider.assert_awaited_once() diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 00000000..deed9140 --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,80 @@ +"""Edge case tests for api/detection.py.""" + +from unittest.mock import patch + +from api.detection import ( + is_prefix_detection_request, + is_filepath_extraction_request, +) +from api.models.anthropic import MessagesRequest, Message + + +def _make_request(content: str, **kwargs) -> MessagesRequest: + return MessagesRequest( + model="claude-3-sonnet", + max_tokens=100, + messages=[Message(role="user", content=content)], + **kwargs, + ) + + +class TestIsPrefixDetectionRequest: + def test_output_marker_handling(self): + """Content with Command: but Output: after cmd_start; output has < or \\n\\n.""" + content = " Command:\nls -la\nOutput:\na.txt\nb.txt\n\nmore" + req = _make_request(content) + is_req, cmd = is_prefix_detection_request(req) + assert is_req is True + assert "ls -la" in cmd + + def test_prefix_detection_with_empty_command_section(self): + """Command: at end with no content returns empty command.""" + req = _make_request(" Command: ") + is_req, cmd = is_prefix_detection_request(req) + assert is_req is True + assert cmd == "" + + def test_exception_in_try_returns_false(self): + """Exception in try block (e.g. content slice) returns False, ''.""" + req = _make_request(" Command: x") + + # Return object that raises when sliced - triggers except in is_prefix_detection_request + class BadStr(str): + def __getitem__(self, key): + raise TypeError("bad slice") + + with patch( + "api.detection.extract_text_from_content", + return_value=BadStr(" Command: x"), + ): + is_req, cmd = is_prefix_detection_request(req) + assert is_req is False + assert cmd == "" + + +class TestIsFilepathExtractionRequest: + def test_output_marker_minus_one_returns_false(self): + """Output: not found after Command: returns False.""" + content = "Command:\nls\nfilepaths" + req = _make_request(content) + is_fp, cmd, out = is_filepath_extraction_request(req) + assert is_fp is False + assert cmd == "" + assert out == "" + + def test_output_has_angle_bracket_splits(self): + """Output containing < is split and first part used.""" + content = "Command:\nls\nOutput:\na.txt b.txt \nfilepaths" + req = _make_request(content) + is_fp, cmd, out = is_filepath_extraction_request(req) + assert is_fp is True + assert "<" not in out + assert out == "a.txt b.txt" + + def test_output_has_double_newline_splits(self): + """Output containing \\n\\n is split and first part used.""" + content = "Command:\nls\nOutput:\na.txt\nb.txt\n\nmore text\nfilepaths" + req = _make_request(content) + is_fp, cmd, out = is_filepath_extraction_request(req) + assert is_fp is True + assert "more" not in out diff --git a/tests/test_discord_markdown.py b/tests/test_discord_markdown.py new file mode 100644 index 00000000..b70a9d64 --- /dev/null +++ b/tests/test_discord_markdown.py @@ -0,0 +1,201 @@ +"""Tests for messaging/discord_markdown.py.""" + +from messaging.discord_markdown import ( + escape_discord, + escape_discord_code, + discord_bold, + discord_code_inline, + format_status_discord, + format_status, + render_markdown_to_discord, + _is_gfm_table_header_line, + _normalize_gfm_tables, +) + + +class TestEscapeDiscord: + """Tests for escape_discord.""" + + def test_empty_string(self): + assert escape_discord("") == "" + + def test_plain_text_unchanged(self): + assert escape_discord("hello world") == "hello world" + + def test_special_chars_escaped(self): + for ch in "\\*_`~|>": + assert escape_discord(ch) == f"\\{ch}" + + def test_mixed_special_and_plain(self): + assert escape_discord("a*b_c") == "a\\*b\\_c" + + def test_unicode_preserved(self): + assert escape_discord("café 日本語") == "café 日本語" + + +class TestEscapeDiscordCode: + """Tests for escape_discord_code.""" + + def test_empty_string(self): + assert escape_discord_code("") == "" + + def test_backslash_escaped(self): + assert escape_discord_code("\\") == "\\\\" + + def test_backtick_escaped(self): + assert escape_discord_code("`") == "\\`" + + def test_both_escaped(self): + assert escape_discord_code("`\\") == "\\`\\\\" + + +class TestDiscordBold: + """Tests for discord_bold.""" + + def test_simple(self): + assert discord_bold("hello") == "**hello**" + + def test_escapes_inner(self): + assert discord_bold("a*b") == "**a\\*b**" + + +class TestDiscordCodeInline: + """Tests for discord_code_inline.""" + + def test_simple(self): + assert discord_code_inline("x") == "`x`" + + def test_escapes_backtick(self): + assert discord_code_inline("`") == "`\\``" + + +class TestFormatStatusDiscord: + """Tests for format_status_discord.""" + + def test_label_only(self): + assert format_status_discord("Running") == "**Running**" + + def test_label_with_suffix(self): + # Parentheses not in DISCORD_SPECIAL, so unchanged + assert ( + format_status_discord("Queued", "(position 2)") == "**Queued** (position 2)" + ) + + +class TestFormatStatus: + """Tests for format_status.""" + + def test_label_only(self): + assert format_status("🔄", "Running") == "🔄 **Running**" + + def test_label_with_suffix(self): + assert format_status("⏳", "Waiting", "5/10") == "⏳ **Waiting** 5/10" + + +class TestIsGfmTableHeaderLine: + """Tests for _is_gfm_table_header_line.""" + + def test_no_pipe_returns_false(self): + assert _is_gfm_table_header_line("hello world") is False + + def test_separator_only_returns_false(self): + assert _is_gfm_table_header_line("|---|") is False + assert _is_gfm_table_header_line("|:---|:---|") is False + + def test_valid_header(self): + assert _is_gfm_table_header_line("| A | B |") is True + assert _is_gfm_table_header_line("A | B") is True + + def test_single_column_returns_false(self): + assert _is_gfm_table_header_line("| A |") is False + + +class TestNormalizeGfmTables: + """Tests for _normalize_gfm_tables.""" + + def test_single_line_unchanged(self): + assert _normalize_gfm_tables("hello") == "hello" + + def test_two_lines_no_table_unchanged(self): + assert _normalize_gfm_tables("a\nb") == "a\nb" + + def test_table_gets_blank_line_before(self): + text = "para\n| A | B |\n|---|\n| 1 | 2 |" + result = _normalize_gfm_tables(text) + assert "para" in result + assert "| A | B |" in result + + def test_table_inside_fence_unchanged(self): + text = "```\n| A | B |\n|---|\n```" + result = _normalize_gfm_tables(text) + assert result == text + + +class TestRenderMarkdownToDiscord: + """Tests for render_markdown_to_discord.""" + + def test_empty_string(self): + assert render_markdown_to_discord("") == "" + + def test_plain_paragraph(self): + assert "hello" in render_markdown_to_discord("hello") + + def test_headings(self): + result = render_markdown_to_discord("# Title\n## Sub") + assert "Title" in result + assert "Sub" in result + + def test_bold_italic(self): + result = render_markdown_to_discord("**bold** *italic*") + assert "bold" in result + assert "italic" in result + + def test_strikethrough(self): + result = render_markdown_to_discord("~~strike~~") + assert "strike" in result + + def test_inline_code(self): + result = render_markdown_to_discord("use `code` here") + assert "`" in result + assert "code" in result + + def test_code_block(self): + result = render_markdown_to_discord("```\nprint(1)\n```") + assert "print(1)" in result + assert "```" in result + + def test_blockquote(self): + result = render_markdown_to_discord("> quote") + assert "quote" in result + + def test_bullet_list(self): + result = render_markdown_to_discord("- a\n- b") + assert "a" in result + assert "b" in result + + def test_ordered_list(self): + result = render_markdown_to_discord("1. first\n2. second") + assert "first" in result + assert "second" in result + + def test_link(self): + result = render_markdown_to_discord("[text](https://example.com)") + assert "text" in result + assert "https://example.com" in result + + def test_image_with_alt(self): + result = render_markdown_to_discord("![alt](https://img.png)") + assert "alt" in result + assert "https://img.png" in result + + def test_image_without_alt(self): + result = render_markdown_to_discord("![](https://img.png)") + assert "https://img.png" in result + + def test_gfm_table(self): + text = "| A | B |\n|---|---|\n| 1 | 2 |" + result = render_markdown_to_discord(text) + assert "A" in result + assert "B" in result + assert "1" in result + assert "2" in result diff --git a/tests/test_discord_platform.py b/tests/test_discord_platform.py index eb6e6750..674cd693 100644 --- a/tests/test_discord_platform.py +++ b/tests/test_discord_platform.py @@ -1,15 +1,29 @@ """Tests for Discord platform adapter.""" +import asyncio import pytest from unittest.mock import AsyncMock, MagicMock, patch from messaging.discord import ( DiscordPlatform, _parse_allowed_channels, + _get_discord, DISCORD_AVAILABLE, ) +class TestGetDiscord: + """Tests for _get_discord helper.""" + + def test_raises_when_discord_not_available(self): + import messaging.discord as discord_mod + + with patch.object(discord_mod, "DISCORD_AVAILABLE", False): + with patch.object(discord_mod, "_discord_module", None): + with pytest.raises(ImportError, match="discord.py is required"): + _get_discord() + + class TestParseAllowedChannels: """Tests for _parse_allowed_channels helper.""" @@ -17,6 +31,9 @@ class TestParseAllowedChannels: assert _parse_allowed_channels("") == set() assert _parse_allowed_channels(None) == set() + def test_whitespace_only_returns_empty_set(self): + assert _parse_allowed_channels(" ") == set() + def test_single_channel(self): assert _parse_allowed_channels("123456789") == {"123456789"} @@ -66,6 +83,22 @@ class TestDiscordPlatform: short = "hello" assert platform._truncate(short) == short + def test_truncate_exactly_at_limit_unchanged(self): + platform = DiscordPlatform(bot_token="token") + exact = "x" * 2000 + assert platform._truncate(exact) == exact + + def test_truncate_one_over_limit_truncates(self): + platform = DiscordPlatform(bot_token="token") + over = "x" * 2001 + result = platform._truncate(over) + assert len(result) == 2000 + assert result.endswith("...") + + def test_truncate_empty_string(self): + platform = DiscordPlatform(bot_token="token") + assert platform._truncate("") == "" + @pytest.mark.asyncio async def test_send_message_returns_message_id(self): platform = DiscordPlatform(bot_token="token") @@ -92,3 +125,237 @@ class TestDiscordPlatform: ): await platform.edit_message("123", "456", "Updated text") mock_msg.edit.assert_called_once_with(content="Updated text") + + @pytest.mark.asyncio + async def test_send_message_channel_not_found_raises(self): + platform = DiscordPlatform(bot_token="token") + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=None) + ): + with pytest.raises(RuntimeError, match="Channel"): + await platform.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_send_message_channel_no_send_raises(self): + platform = DiscordPlatform(bot_token="token") + platform._connected = True + mock_channel = MagicMock(spec=[]) # No send attr + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + with pytest.raises(RuntimeError, match="Channel"): + await platform.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_queue_send_message_without_limiter_calls_send_message(self): + platform = DiscordPlatform(bot_token="token") + platform._limiter = None + platform._connected = True + mock_channel = AsyncMock() + mock_msg = MagicMock() + mock_msg.id = 42 + mock_channel.send = AsyncMock(return_value=mock_msg) + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + result = await platform.queue_send_message("123", "hi") + assert result == "42" + mock_channel.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_queue_edit_message_without_limiter_calls_edit_message(self): + platform = DiscordPlatform(bot_token="token") + platform._limiter = None + platform._connected = True + mock_msg = AsyncMock() + mock_channel = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_msg) + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + await platform.queue_edit_message("123", "456", "Updated") + mock_msg.edit.assert_called_once_with(content="Updated") + + @pytest.mark.asyncio + async def test_on_discord_message_bot_ignored(self): + platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = True + msg.content = "hello" + msg.channel.id = 123 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_empty_content_ignored(self): + platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.content = "" + msg.channel.id = 123 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_channel_not_allowed_ignored(self): + platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.content = "hello" + msg.channel.id = 999 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_valid_calls_handler(self): + platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.author.id = 456 + msg.author.display_name = "User" + msg.content = "hello" + msg.channel.id = 123 + msg.id = 789 + msg.reference = None + await platform._on_discord_message(msg) + handler.assert_awaited_once() + call = handler.call_args[0][0] + assert call.text == "hello" + assert call.chat_id == "123" + assert call.user_id == "456" + assert call.message_id == "789" + assert call.platform == "discord" + + @pytest.mark.asyncio + async def test_send_message_with_reply_to(self): + platform = DiscordPlatform(bot_token="token") + mock_msg = MagicMock() + mock_msg.id = 999 + mock_channel = AsyncMock() + mock_channel.send = AsyncMock(return_value=mock_msg) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + with patch("messaging.discord._get_discord") as mock_get: + mock_discord = MagicMock() + mock_get.return_value = mock_discord + msg_id = await platform.send_message("123", "Hello", reply_to="456") + assert msg_id == "999" + mock_channel.send.assert_awaited_once() + call_kw = mock_channel.send.call_args[1] + assert call_kw.get("reference") is not None + + @pytest.mark.asyncio + async def test_edit_message_not_found_returns_gracefully(self): + import discord as discord_pkg + + platform = DiscordPlatform(bot_token="token") + mock_channel = AsyncMock() + mock_resp = MagicMock() + mock_resp.status = 404 + mock_channel.fetch_message = AsyncMock( + side_effect=discord_pkg.NotFound(mock_resp, "Not found") + ) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + await platform.edit_message("123", "456", "Updated") + # Should not raise - NotFound is caught and we return + + @pytest.mark.asyncio + async def test_delete_message(self): + platform = DiscordPlatform(bot_token="token") + mock_msg = AsyncMock() + mock_channel = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_msg) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + with patch("messaging.discord._get_discord") as mock_get: + mock_get.return_value = MagicMock() + await platform.delete_message("123", "456") + mock_msg.delete.assert_awaited_once() + + @pytest.mark.asyncio + async def test_fire_and_forget_with_coroutine(self): + platform = DiscordPlatform(bot_token="token") + + async def _task(): + pass + + coro = _task() + with patch("asyncio.create_task") as mock_create: + + def _run(c): + return asyncio.ensure_future(c) + + mock_create.side_effect = _run + platform.fire_and_forget(coro) + mock_create.assert_called_once() + await asyncio.sleep(0) + + def test_on_message_registers_handler(self): + platform = DiscordPlatform(bot_token="token") + handler = AsyncMock() + platform.on_message(handler) + assert platform._message_handler is handler + + @pytest.mark.asyncio + async def test_start_requires_token(self): + with patch.dict("os.environ", {"DISCORD_BOT_TOKEN": ""}, clear=False): + platform = DiscordPlatform(bot_token="") + with pytest.raises(ValueError, match="DISCORD_BOT_TOKEN"): + await platform.start() + + @pytest.mark.asyncio + async def test_start_connects(self): + platform = DiscordPlatform(bot_token="token") + + async def _fake_start(_token): + platform._connected = True + + with patch.object( + platform._client, "start", new_callable=AsyncMock, side_effect=_fake_start + ): + with patch( + "messaging.limiter.MessagingRateLimiter.get_instance", + new_callable=AsyncMock, + ): + await platform.start() + assert platform.is_connected is True + + @pytest.mark.asyncio + async def test_stop_when_already_closed(self): + platform = DiscordPlatform(bot_token="token") + platform._connected = True + with patch.object( + platform._client, "is_closed", new_callable=MagicMock, return_value=True + ): + await platform.stop() + assert platform.is_connected is False + + @pytest.mark.asyncio + async def test_stop_closes_client(self): + platform = DiscordPlatform(bot_token="token") + platform._connected = True + mock_close = AsyncMock() + with patch.object( + platform._client, "is_closed", new_callable=MagicMock, return_value=False + ): + with patch.object(platform._client, "close", mock_close): + platform._start_task = None + await platform.stop() + mock_close.assert_awaited_once() + assert platform.is_connected is False diff --git a/tests/test_handler.py b/tests/test_handler.py index 6b387a8f..3fa583d5 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -11,6 +11,47 @@ def handler(mock_platform, mock_cli_manager, mock_session_store): return ClaudeMessageHandler(mock_platform, mock_cli_manager, mock_session_store) +def test_get_initial_status_new_conversation_with_slot(handler): + """New conversation when slots available returns launching message.""" + handler.cli_manager.get_stats.return_value = { + "active_sessions": 0, + "max_sessions": 5, + } + result = handler._get_initial_status(None, None) + assert "Launching" in result + + +def test_get_initial_status_new_conversation_at_capacity(handler): + """New conversation at capacity returns waiting message.""" + handler.cli_manager.get_stats.return_value = { + "active_sessions": 5, + "max_sessions": 5, + } + result = handler._get_initial_status(None, None) + assert "Waiting" in result + assert "5/5" in result + + +def test_get_initial_status_reply_tree_busy_queued(handler): + """Reply to tree when busy returns queued message.""" + mock_queue = MagicMock() + mock_queue.is_node_tree_busy.return_value = True + mock_queue.get_queue_size.return_value = 2 + handler.tree_queue = mock_queue + result = handler._get_initial_status(MagicMock(), "parent_1") + assert "Queued" in result + assert "position 3" in result + + +def test_get_initial_status_reply_tree_not_busy_continuing(handler): + """Reply to tree when not busy returns continuing message.""" + mock_queue = MagicMock() + mock_queue.is_node_tree_busy.return_value = False + handler.tree_queue = mock_queue + result = handler._get_initial_status(MagicMock(), "parent_1") + assert "Continuing" in result + + @pytest.mark.asyncio async def test_handle_message_stop_command( handler, mock_platform, incoming_message_factory diff --git a/tests/test_nvidia_nim_request.py b/tests/test_nvidia_nim_request.py new file mode 100644 index 00000000..5c273833 --- /dev/null +++ b/tests/test_nvidia_nim_request.py @@ -0,0 +1,101 @@ +"""Tests for providers/nvidia_nim/request.py.""" + +from unittest.mock import MagicMock + +from providers.nvidia_nim.request import ( + _set_if_not_none, + _set_extra, + build_request_body, +) +from config.nim import NimSettings + + +class TestSetIfNotNone: + def test_value_not_none_sets(self): + body = {} + _set_if_not_none(body, "key", "value") + assert body["key"] == "value" + + def test_value_none_skips(self): + body = {} + _set_if_not_none(body, "key", None) + assert "key" not in body + + +class TestSetExtra: + def test_key_in_extra_body_skips(self): + extra = {"top_k": 42} + _set_extra(extra, "top_k", 10) + assert extra["top_k"] == 42 + + def test_value_none_skips(self): + extra = {} + _set_extra(extra, "top_k", None) + assert "top_k" not in extra + + def test_value_equals_ignore_value_skips(self): + extra = {} + _set_extra(extra, "top_k", -1, ignore_value=-1) + assert "top_k" not in extra + + def test_value_set_when_valid(self): + extra = {} + _set_extra(extra, "top_k", 10, ignore_value=-1) + assert extra["top_k"] == 10 + + +class TestBuildRequestBody: + def test_max_tokens_capped_by_nim(self): + """Request max_tokens exceeds nim.max_tokens -> capped.""" + req = MagicMock() + req.model = "test" + req.messages = [MagicMock(role="user", content="hi")] + req.max_tokens = 100000 + req.system = None + req.temperature = None + req.top_p = None + req.stop_sequences = None + req.tools = None + req.tool_choice = None + req.extra_body = None + req.top_k = None + + nim = NimSettings(max_tokens=4096) + body = build_request_body(req, nim) + assert body["max_tokens"] == 4096 + + def test_presence_penalty_included_when_nonzero(self): + req = MagicMock() + req.model = "test" + req.messages = [MagicMock(role="user", content="hi")] + req.max_tokens = 100 + req.system = None + req.temperature = None + req.top_p = None + req.stop_sequences = None + req.tools = None + req.tool_choice = None + req.extra_body = None + req.top_k = None + + nim = NimSettings(presence_penalty=0.5) + body = build_request_body(req, nim) + assert body["presence_penalty"] == 0.5 + + def test_parallel_tool_calls_included(self): + req = MagicMock() + req.model = "test" + req.messages = [MagicMock(role="user", content="hi")] + req.max_tokens = 100 + req.system = None + req.temperature = None + req.top_p = None + req.stop_sequences = None + req.tools = None + req.tool_choice = None + req.extra_body = None + req.top_k = None + + nim = NimSettings(parallel_tool_calls=False) + body = build_request_body(req, nim) + assert body["parallel_tool_calls"] is False diff --git a/tests/test_open_router.py b/tests/test_open_router.py index 3d064193..7f3b58ce 100644 --- a/tests/test_open_router.py +++ b/tests/test_open_router.py @@ -204,3 +204,152 @@ async def test_stream_response_reasoning_content(open_router_provider): if "Thinking..." in e: found_thinking = True assert found_thinking + + +@pytest.mark.asyncio +async def test_stream_response_empty_choices_skipped(open_router_provider): + """Chunks with empty choices are skipped.""" + req = MockRequest() + + async def mock_stream(): + yield MagicMock(choices=[], usage=None) + yield MagicMock( + choices=[ + MagicMock( + delta=MagicMock(content="ok", reasoning_content=None), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=2), + ) + + with patch.object( + open_router_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + events = [] + async for event in open_router_provider.stream_response(req): + events.append(event) + assert any("content_block_delta" in e and "ok" in e for e in events) + + +@pytest.mark.asyncio +async def test_stream_response_delta_none_skipped(open_router_provider): + """Chunks with delta=None are skipped.""" + req = MockRequest() + + async def mock_stream(): + yield MagicMock( + choices=[MagicMock(delta=None, finish_reason=None)], + usage=None, + ) + yield MagicMock( + choices=[ + MagicMock( + delta=MagicMock(content="x", reasoning_content=None), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=1), + ) + + with patch.object( + open_router_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + events = [] + async for event in open_router_provider.stream_response(req): + events.append(event) + assert any("x" in e for e in events) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_details(open_router_provider): + """Streaming with reasoning_details (stepfun format).""" + req = MockRequest() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content=None, + reasoning_details=[{"text": "Step 1"}], + ), + finish_reason=None, + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + yield MagicMock( + choices=[ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content=None, + reasoning_details=None, + ), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=5), + ) + + with patch.object( + open_router_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + events = [] + async for event in open_router_provider.stream_response(req): + events.append(event) + assert any("Step 1" in e for e in events) + + +@pytest.mark.asyncio +async def test_stream_response_error_path(open_router_provider): + """Stream raises exception -> error event emitted.""" + req = MockRequest() + + async def mock_stream(): + raise RuntimeError("API failed") + yield # unreachable, makes it a generator + + with patch.object( + open_router_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + events = [] + async for event in open_router_provider.stream_response(req): + events.append(event) + # Error is emitted; message_stop/done indicates stream completed + assert any("API failed" in e for e in events) + assert any("message_stop" in e for e in events) + + +@pytest.mark.asyncio +async def test_stream_response_finish_reason_only(open_router_provider): + """Chunk with finish_reason but no content still completes.""" + req = MockRequest() + + async def mock_stream(): + yield MagicMock( + choices=[ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=0), + ) + + with patch.object( + open_router_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + events = [] + async for event in open_router_provider.stream_response(req): + events.append(event) + assert any("message_delta" in e for e in events) + assert any("message_stop" in e for e in events) diff --git a/tests/test_optimization_handlers.py b/tests/test_optimization_handlers.py new file mode 100644 index 00000000..b2d4c797 --- /dev/null +++ b/tests/test_optimization_handlers.py @@ -0,0 +1,219 @@ +"""Tests for api/optimization_handlers.py.""" + +from unittest.mock import patch + +from api.optimization_handlers import ( + try_prefix_detection, + try_quota_mock, + try_title_skip, + try_suggestion_skip, + try_filepath_mock, + try_optimizations, +) +from api.models.anthropic import MessagesRequest, Message, ContentBlockText +from config.settings import Settings + + +def _make_request( + messages_content: str, max_tokens: int | None = None +) -> MessagesRequest: + """Create a MessagesRequest with a single user message.""" + return MessagesRequest( + model="claude-3-sonnet", + max_tokens=max_tokens if max_tokens is not None else 100, + messages=[Message(role="user", content=messages_content)], + ) + + +class TestTryPrefixDetection: + def test_disabled_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = False + req = _make_request("x") + with patch( + "api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ): + assert try_prefix_detection(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.fast_prefix_detection = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ): + with patch( + "api.optimization_handlers.extract_command_prefix", + return_value="/ask", + ): + result = try_prefix_detection(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "/ask" + + def test_enabled_but_no_match_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_prefix_detection_request", + return_value=(False, ""), + ): + assert try_prefix_detection(req, settings) is None + + +class TestTryQuotaMock: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_network_probe_mock = False + req = _make_request("quota", max_tokens=1) + with patch( + "api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + assert try_quota_mock(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_network_probe_mock = True + req = _make_request("quota", max_tokens=1) + with patch( + "api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + result = try_quota_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert "Quota check passed" in block.text + + +class TestTryTitleSkip: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_title_generation_skip = False + req = _make_request("write a 5-10 word title") + with patch( + "api.optimization_handlers.is_title_generation_request", + return_value=True, + ): + assert try_title_skip(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_title_generation_skip = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_title_generation_request", + return_value=True, + ): + result = try_title_skip(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "Conversation" + + +class TestTrySuggestionSkip: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_suggestion_mode_skip = False + req = _make_request("[SUGGESTION MODE: x]") + with patch( + "api.optimization_handlers.is_suggestion_mode_request", + return_value=True, + ): + assert try_suggestion_skip(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_suggestion_mode_skip = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_suggestion_mode_request", + return_value=True, + ): + result = try_suggestion_skip(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "" + + +class TestTryFilepathMock: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_filepath_extraction_mock = False + req = _make_request("Command:\nls\nOutput:\nfilepaths") + with patch( + "api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "out"), + ): + assert try_filepath_mock(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_filepath_extraction_mock = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "a.txt b.txt"), + ): + with patch( + "api.optimization_handlers.extract_filepaths_from_command", + return_value="a.txt\nb.txt", + ): + result = try_filepath_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "a.txt\nb.txt" + + def test_extract_filepaths_empty_list_still_returns_response(self): + settings = Settings() + settings.enable_filepath_extraction_mock = True + req = _make_request("x") + with patch( + "api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "out"), + ): + with patch( + "api.optimization_handlers.extract_filepaths_from_command", + return_value="", + ): + result = try_filepath_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "" + + +class TestTryOptimizations: + def test_first_match_wins(self): + """Quota mock is first in OPTIMIZATION_HANDLERS; it should win over prefix.""" + settings = Settings() + settings.enable_network_probe_mock = True + settings.fast_prefix_detection = True + req = _make_request("quota", max_tokens=1) + with patch( + "api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + result = try_optimizations(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert "Quota check passed" in block.text + + def test_no_match_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = False + settings.enable_network_probe_mock = False + settings.enable_title_generation_skip = False + settings.enable_suggestion_mode_skip = False + settings.enable_filepath_extraction_mock = False + req = _make_request("random user message") + assert try_optimizations(req, settings) is None diff --git a/tests/test_process_registry.py b/tests/test_process_registry.py index b97b6366..36032032 100644 --- a/tests/test_process_registry.py +++ b/tests/test_process_registry.py @@ -1,4 +1,48 @@ import os +from unittest.mock import patch + + +def test_process_registry_register_pid_zero_noop(): + """register_pid(0) is a no-op (early return).""" + from cli import process_registry as pr + + before = len(pr._pids) + pr.register_pid(0) + assert len(pr._pids) == before + + +def test_process_registry_unregister_pid_zero_noop(): + """unregister_pid(0) is a no-op.""" + from cli import process_registry as pr + + pr.register_pid(99999) + pr.unregister_pid(0) + assert 99999 in pr._pids + pr.unregister_pid(99999) + + +def test_process_registry_ensure_atexit_idempotent(): + """Second call to ensure_atexit_registered is idempotent.""" + from cli import process_registry as pr + + pr.ensure_atexit_registered() + pr.ensure_atexit_registered() + # Should not raise; atexit handler registered once + + +def test_process_registry_kill_all_exception_logged_no_raise(monkeypatch): + """Exception in os.kill/taskkill is logged but does not raise.""" + from cli import process_registry as pr + + monkeypatch.setattr(pr, "_pids", {99999}) + monkeypatch.setattr(os, "name", "posix", raising=False) + + def _kill_raises(pid, sig): + raise ProcessLookupError("no such process") + + with patch("os.kill", _kill_raises): + pr.kill_all_best_effort() + # Should not raise def test_process_registry_register_unregister_does_not_crash(): diff --git a/tests/test_provider_rate_limit.py b/tests/test_provider_rate_limit.py index 9a328d53..3a2fa437 100644 --- a/tests/test_provider_rate_limit.py +++ b/tests/test_provider_rate_limit.py @@ -183,3 +183,72 @@ class TestProviderRateLimiter: f"Rolling window violated at i={i}: " f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s" ) + + @pytest.mark.asyncio + async def test_init_rate_limit_zero_raises(self): + """rate_limit <= 0 raises ValueError.""" + GlobalRateLimiter.reset_instance() + with pytest.raises(ValueError, match="rate_limit must be > 0"): + GlobalRateLimiter(rate_limit=0, rate_window=60) + + @pytest.mark.asyncio + async def test_init_rate_window_zero_raises(self): + """rate_window <= 0 raises ValueError.""" + GlobalRateLimiter.reset_instance() + with pytest.raises(ValueError, match="rate_window must be > 0"): + GlobalRateLimiter(rate_limit=10, rate_window=0) + + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_retries_raises(self): + """When all 429 retries exhausted, last exception is raised.""" + import openai + from httpx import Response, Request + + GlobalRateLimiter.reset_instance() + limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60) + + def make_429(): + return openai.RateLimitError( + "rate limited", + response=Response(429, request=Request("POST", "http://x")), + body={}, + ) + + async def fail(): + raise make_429() + + with pytest.raises(openai.RateLimitError): + await limiter.execute_with_retry( + fail, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_retry(self): + """429 then success returns result.""" + import openai + from httpx import Response, Request + + GlobalRateLimiter.reset_instance() + limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60) + + def make_429(): + return openai.RateLimitError( + "rate limited", + response=Response(429, request=Request("POST", "http://x")), + body={}, + ) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise make_429() + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + assert result == "ok" + assert call_count == 2 diff --git a/tests/test_routes_optimizations.py b/tests/test_routes_optimizations.py index 2f165a25..eb281c00 100644 --- a/tests/test_routes_optimizations.py +++ b/tests/test_routes_optimizations.py @@ -97,6 +97,20 @@ def test_count_tokens_endpoint(client): assert response.json()["input_tokens"] == 5 +def test_count_tokens_error_returns_500(client): + """When get_token_count raises, count_tokens returns 500.""" + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("api.routes.get_token_count", side_effect=RuntimeError("token error")): + response = client.post("/v1/messages/count_tokens", json=payload) + + assert response.status_code == 500 + assert "token error" in response.json()["detail"] + + def test_stop_cli_with_handler(client): mock_handler = MagicMock() # Mock the async method to return a completed future or just mock it since TestClient