mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-19 02:26:12 +03:00
test: strengthen user-path coverage and CI gates (#5308)
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.webui.forking as forking
|
||||
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY
|
||||
|
||||
|
||||
def test_create_fork_rebuilds_missing_transcript_and_saves_clean_title(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
forked = SimpleNamespace(messages=[{"role": "user", "content": "hello"}], metadata={})
|
||||
manager = MagicMock()
|
||||
manager.fork_session_before_user_index.return_value = forked
|
||||
rebuild = MagicMock()
|
||||
marker = MagicMock()
|
||||
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "fork-id")
|
||||
monkeypatch.setattr(forking, "fork_transcript_before_user_index", lambda *_args: False)
|
||||
monkeypatch.setattr(forking, "write_session_messages_as_transcript", rebuild)
|
||||
monkeypatch.setattr(forking, "append_fork_marker", marker)
|
||||
|
||||
result = forking.create_webui_chat_fork(
|
||||
manager,
|
||||
source_chat_id="source",
|
||||
before_user_index=2,
|
||||
title=" Useful fork ",
|
||||
)
|
||||
|
||||
assert result == ("fork-id", "websocket:fork-id")
|
||||
manager.fork_session_before_user_index.assert_called_once_with(
|
||||
"websocket:source",
|
||||
"websocket:fork-id",
|
||||
2,
|
||||
)
|
||||
rebuild.assert_called_once_with("websocket:fork-id", forked.messages)
|
||||
marker.assert_called_once_with("websocket:fork-id")
|
||||
assert forked.metadata[WEBUI_TITLE_METADATA_KEY] == "Useful fork"
|
||||
manager.save.assert_called_once_with(forked, fsync=True)
|
||||
|
||||
|
||||
def test_create_fork_rolls_back_session_and_transcript_together(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = MagicMock()
|
||||
manager.fork_session_before_user_index.return_value = SimpleNamespace(
|
||||
messages=[],
|
||||
metadata={},
|
||||
)
|
||||
delete_transcript = MagicMock()
|
||||
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "failed-fork")
|
||||
monkeypatch.setattr(
|
||||
forking,
|
||||
"fork_transcript_before_user_index",
|
||||
MagicMock(side_effect=OSError("disk full")),
|
||||
)
|
||||
monkeypatch.setattr(forking, "delete_webui_transcript", delete_transcript)
|
||||
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
forking.create_webui_chat_fork(
|
||||
manager,
|
||||
source_chat_id="source",
|
||||
before_user_index=1,
|
||||
)
|
||||
|
||||
delete_transcript.assert_called_once_with("websocket:failed-fork")
|
||||
manager.delete_session.assert_called_once_with("websocket:failed-fork")
|
||||
|
||||
|
||||
def test_create_fork_stops_before_transcript_work_when_source_is_invalid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = MagicMock()
|
||||
manager.fork_session_before_user_index.return_value = None
|
||||
fork_transcript = MagicMock()
|
||||
monkeypatch.setattr(forking, "fork_transcript_before_user_index", fork_transcript)
|
||||
|
||||
result = forking.create_webui_chat_fork(
|
||||
manager,
|
||||
source_chat_id="missing",
|
||||
before_user_index=1,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fork_transcript.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("envelope", "detail"),
|
||||
[
|
||||
({"source_chat_id": "bad/id", "before_user_index": 0}, "invalid source_chat_id"),
|
||||
({"source_chat_id": "source", "before_user_index": True}, "invalid before_user_index"),
|
||||
({"source_chat_id": "source", "before_user_index": -1}, "invalid before_user_index"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_handler_rejects_invalid_protocol_input(
|
||||
envelope: dict[str, object],
|
||||
detail: str,
|
||||
) -> None:
|
||||
connection = object()
|
||||
channel = SimpleNamespace(
|
||||
send_webui_protocol_error=AsyncMock(),
|
||||
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||
)
|
||||
|
||||
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||
|
||||
channel.send_webui_protocol_error.assert_awaited_once_with(connection, detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_handler_reports_unavailable_session_manager() -> None:
|
||||
connection = object()
|
||||
channel = SimpleNamespace(
|
||||
send_webui_protocol_error=AsyncMock(),
|
||||
gateway=SimpleNamespace(session_manager=None),
|
||||
)
|
||||
|
||||
await forking.handle_webui_fork_chat(
|
||||
channel,
|
||||
connection,
|
||||
{"source_chat_id": "source", "before_user_index": 0},
|
||||
)
|
||||
|
||||
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||
connection,
|
||||
"session_manager_unavailable",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_handler_maps_invalid_source_and_internal_failure_to_stable_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
connection = object()
|
||||
channel = SimpleNamespace(
|
||||
send_webui_protocol_error=AsyncMock(),
|
||||
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||
logger=SimpleNamespace(warning=MagicMock()),
|
||||
)
|
||||
envelope = {"source_chat_id": "source", "before_user_index": 0}
|
||||
monkeypatch.setattr(forking, "create_webui_chat_fork", lambda *_args, **_kwargs: None)
|
||||
|
||||
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||
connection,
|
||||
"invalid fork source or index",
|
||||
)
|
||||
|
||||
channel.send_webui_protocol_error.reset_mock()
|
||||
monkeypatch.setattr(
|
||||
forking,
|
||||
"create_webui_chat_fork",
|
||||
MagicMock(side_effect=RuntimeError("broken transcript")),
|
||||
)
|
||||
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||
|
||||
channel.logger.warning.assert_called_once_with("fork_chat failed: {}", ANY)
|
||||
channel.send_webui_protocol_error.assert_awaited_once_with(connection, "fork_chat_failed")
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
@@ -138,3 +139,62 @@ async def test_model_preset_mutation_routes(
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["routed"] == function_name
|
||||
assert captured["query"] == expected_query
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("update_info", "expected"),
|
||||
[
|
||||
(None, {"updateAvailable": None}),
|
||||
(
|
||||
{
|
||||
"currentVersion": "1.2.0",
|
||||
"latestVersion": "1.3.0",
|
||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||
},
|
||||
{
|
||||
"updateAvailable": {
|
||||
"currentVersion": "1.2.0",
|
||||
"latestVersion": "1.3.0",
|
||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_check_route_returns_stable_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
update_info: dict[str, str] | None,
|
||||
expected: dict[str, object],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.check_for_update",
|
||||
lambda: update_info,
|
||||
)
|
||||
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||
|
||||
response = await _router().dispatch(None, request, request.path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_check_route_enforces_auth_and_bounds_failures(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
|
||||
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||
|
||||
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
|
||||
assert unauthorized is not None
|
||||
assert unauthorized.status_code == 401
|
||||
check.assert_not_called()
|
||||
|
||||
failed = await _router().dispatch(None, request, request.path)
|
||||
assert failed is not None
|
||||
assert failed.status_code == 500
|
||||
assert json.loads(failed.body) == {"error": "version check failed"}
|
||||
assert "upstream secret body" not in failed.body.decode()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.webui.version_check as version_check
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_version_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(version_check, "_cache", (0.0, None))
|
||||
monkeypatch.setattr(version_check.time, "monotonic", lambda: 1_000.0)
|
||||
|
||||
|
||||
def _pypi_response(latest: object) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"info": {"version": latest}}
|
||||
return response
|
||||
|
||||
|
||||
def test_version_check_reports_only_a_newer_release_and_caches_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
get = MagicMock(return_value=_pypi_response("1.3.0"))
|
||||
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||
|
||||
expected = {
|
||||
"currentVersion": "1.2.0",
|
||||
"latestVersion": "1.3.0",
|
||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||
}
|
||||
assert version_check.check_for_update() == expected
|
||||
assert version_check.check_for_update() == expected
|
||||
get.assert_called_once_with(
|
||||
"https://pypi.org/pypi/nanobot-ai/json",
|
||||
timeout=5.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("latest", ["1.2.0", "1.1.9", "not-a-version", 42, None])
|
||||
def test_version_check_ignores_non_newer_or_invalid_releases(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
latest: object,
|
||||
) -> None:
|
||||
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||
monkeypatch.setattr(version_check.httpx, "get", lambda *_args, **_kwargs: _pypi_response(latest))
|
||||
|
||||
assert version_check.check_for_update() is None
|
||||
|
||||
|
||||
def test_version_check_treats_network_failure_as_best_effort(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
get = MagicMock(side_effect=TimeoutError("offline"))
|
||||
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||
|
||||
assert version_check.check_for_update() is None
|
||||
|
||||
# Failures are not cached, so a later explicit check can recover.
|
||||
assert version_check.check_for_update() is None
|
||||
assert get.call_count == 2
|
||||
Reference in New Issue
Block a user