"""Unit tests for the Nextcloud Talk (spreed) HTTP client.""" import logging import httpx import pytest from nextcloud_mcp_server.client.talk import TalkClient, _validate_token from nextcloud_mcp_server.models.talk import ( TalkConversation, TalkMessage, TalkParticipant, ) from tests.client.conftest import ( create_mock_error_response, create_mock_response, create_mock_talk_message_response, create_mock_talk_room_response, ) logger = logging.getLogger(__name__) pytestmark = pytest.mark.unit # Token validation @pytest.mark.parametrize( "bad_token", [ "", "../foo", "a/b", "a b", "a.b", "a-b", "token!", ], ) def test_validate_token_rejects_invalid(bad_token): """_validate_token rejects anything outside the alphanumeric whitelist.""" with pytest.raises(ValueError, match="Invalid Talk conversation token"): _validate_token(bad_token) @pytest.mark.parametrize("good_token", ["a1b2c3d4", "ABC123", "abcdef", "1"]) def test_validate_token_accepts_valid(good_token): """Real spreed tokens — short alphanumeric strings — pass through.""" _validate_token(good_token) async def test_talk_get_conversation_rejects_path_traversal(mocker): """Pathological tokens never reach the HTTP layer.""" mock_make_request = mocker.patch.object(TalkClient, "_make_request") client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") with pytest.raises(ValueError, match="Invalid Talk conversation token"): await client.get_conversation("../etc/passwd") mock_make_request.assert_not_called() # Conversation tests async def test_talk_list_conversations(mocker): """list_conversations parses the OCS-wrapped room list.""" mock_response = create_mock_response( status_code=200, json_data={ "ocs": { "meta": {"status": "ok"}, "data": [ { "id": 1, "token": "abc", "type": 2, "name": "Group 1", "displayName": "Group 1", "lastMessage": [], }, { "id": 2, "token": "def", "type": 3, "name": "Public Room", "displayName": "Public Room", "lastMessage": [], }, ], } }, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") rooms = await client.list_conversations() assert len(rooms) == 2 assert all(isinstance(r, TalkConversation) for r in rooms) assert rooms[0].token == "abc" assert rooms[1].room_type == 3 mock_make_request.assert_called_once() call_args = mock_make_request.call_args assert call_args[0][0] == "GET" assert call_args[0][1] == "/ocs/v2.php/apps/spreed/api/v4/room" # noStatusUpdate defaults to True assert call_args[1]["params"]["noStatusUpdate"] == 1 async def test_talk_list_conversations_with_modified_since(mocker): """modifiedSince and includeStatus are forwarded as query params.""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.list_conversations(modified_since=1700000000, include_status=True) params = mock_make_request.call_args[1]["params"] assert params["modifiedSince"] == 1700000000 assert params["includeStatus"] == 1 async def test_talk_get_conversation(mocker): """get_conversation parses the OCS-wrapped room object.""" mock_response = create_mock_talk_room_response( conversation_id=42, token="xyz", name="My Room" ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") room = await client.get_conversation("xyz") assert isinstance(room, TalkConversation) assert room.id == 42 assert room.token == "xyz" assert room.name == "My Room" assert mock_make_request.call_args[0][0] == "GET" assert "/api/v4/room/xyz" in mock_make_request.call_args[0][1] async def test_talk_get_conversation_not_found(mocker): """A 404 from spreed propagates as HTTPStatusError.""" error_response = create_mock_error_response(404, "Conversation not found") mock_make_request = mocker.patch.object(TalkClient, "_make_request") mock_make_request.side_effect = httpx.HTTPStatusError( "404 Not Found", request=httpx.Request("GET", "http://test.local"), response=error_response, ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") with pytest.raises(httpx.HTTPStatusError) as excinfo: await client.get_conversation("nope") assert excinfo.value.response.status_code == 404 async def test_talk_create_conversation(mocker): """create_conversation builds the right POST body.""" mock_response = create_mock_talk_room_response( conversation_id=10, token="new", name="New Room" ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") room = await client.create_conversation(room_name="New Room") assert room.token == "new" call_args = mock_make_request.call_args assert call_args[0][0] == "POST" assert call_args[1]["json"] == {"roomType": 2, "roomName": "New Room"} async def test_talk_delete_conversation(mocker): """delete_conversation issues DELETE on the room URL.""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.delete_conversation("doomed") call_args = mock_make_request.call_args assert call_args[0][0] == "DELETE" assert "/api/v4/room/doomed" in call_args[0][1] # Chat tests async def test_talk_get_messages(mocker): """get_messages parses the OCS-wrapped message list.""" mock_response = create_mock_response( status_code=200, headers={"X-Chat-Last-Given": "100"}, json_data={ "ocs": { "meta": {"status": "ok"}, "data": [ { "id": 101, "token": "abc", "actorType": "users", "actorId": "alice", "actorDisplayName": "Alice", "timestamp": 1700000000, "messageType": "comment", "message": "Hi", }, { "id": 102, "token": "abc", "actorType": "users", "actorId": "bob", "actorDisplayName": "Bob", "timestamp": 1700000010, "messageType": "comment", "message": "Hello", }, ], } }, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") messages, last_given = await client.get_messages("abc", limit=20) assert len(messages) == 2 assert all(isinstance(m, TalkMessage) for m in messages) assert messages[0].id == 101 assert messages[1].actorId == "bob" assert last_given == 100 call_args = mock_make_request.call_args assert call_args[0][0] == "GET" assert "/chat/abc" in call_args[0][1] params = call_args[1]["params"] assert params["limit"] == 20 # Defaults: not look_into_future, not set_read_marker assert params["lookIntoFuture"] == 0 assert params["setReadMarker"] == 0 async def test_talk_get_messages_pagination_cursor(mocker): """last_known_message_id is forwarded as the spreed cursor param.""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") messages, last_given = await client.get_messages( "abc", last_known_message_id=500, include_last_known=True ) assert messages == [] assert last_given is None # No header on this response params = mock_make_request.call_args[1]["params"] assert params["lastKnownMessageId"] == 500 assert params["includeLastKnown"] == 1 async def test_talk_get_messages_invalid_last_given_header(mocker, caplog): """A non-numeric X-Chat-Last-Given falls back to None and logs a warning.""" mock_response = create_mock_response( status_code=200, headers={"X-Chat-Last-Given": "not-a-number"}, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mocker.patch.object(TalkClient, "_make_request", return_value=mock_response) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") with caplog.at_level(logging.WARNING, logger="nextcloud_mcp_server.client.talk"): messages, last_given = await client.get_messages("abc") assert messages == [] assert last_given is None assert any( "Invalid X-Chat-Last-Given" in record.message for record in caplog.records ), "Expected a warning log for the malformed header" async def test_talk_send_message(mocker): """send_message posts the message text and parses the response.""" mock_response = create_mock_talk_message_response( message_id=999, token="abc", text="Posted from MCP" ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") posted = await client.send_message("abc", "Posted from MCP") assert isinstance(posted, TalkMessage) assert posted.id == 999 assert posted.message == "Posted from MCP" call_args = mock_make_request.call_args assert call_args[0][0] == "POST" assert "/chat/abc" in call_args[0][1] body = call_args[1]["json"] assert body["message"] == "Posted from MCP" # Optional fields are not sent unless explicitly set assert "replyTo" not in body assert "referenceId" not in body assert "silent" not in body async def test_talk_send_message_with_reference_id_and_reply(mocker): """send_message forwards reply_to, reference_id, silent.""" mock_response = create_mock_talk_message_response( message_id=1000, token="abc", text="A reply" ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.send_message( "abc", "A reply", reply_to=999, reference_id="dedupe-token", silent=True, ) body = mock_make_request.call_args[1]["json"] assert body == { "message": "A reply", "replyTo": 999, "referenceId": "dedupe-token", "silent": True, } async def test_talk_mark_as_read_no_message(mocker): """mark_as_read with no message ID sends no body (json=None).""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.mark_as_read("abc") call_args = mock_make_request.call_args assert call_args[0][0] == "POST" assert "/chat/abc/read" in call_args[0][1] # Empty body is sent as ``json=None`` so httpx skips both the body and # the ``Content-Type: application/json`` header for this bodyless POST. assert call_args[1]["json"] is None async def test_talk_mark_as_read_with_message(mocker): """mark_as_read sends lastReadMessage when provided.""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.mark_as_read("abc", last_read_message=500) assert mock_make_request.call_args[1]["json"] == {"lastReadMessage": 500} # Participant tests async def test_talk_list_participants(mocker): """list_participants parses the OCS-wrapped participant list.""" mock_response = create_mock_response( status_code=200, json_data={ "ocs": { "meta": {"status": "ok"}, "data": [ { "attendeeId": 1, "actorType": "users", "actorId": "alice", "displayName": "Alice", "participantType": 1, "inCall": 0, "lastPing": 1700000000, }, { "attendeeId": 2, "actorType": "users", "actorId": "bob", "displayName": "Bob", "participantType": 3, "inCall": 0, "lastPing": 1700000000, }, ], } }, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") participants = await client.list_participants("abc") assert len(participants) == 2 assert all(isinstance(p, TalkParticipant) for p in participants) assert participants[0].actorId == "alice" assert participants[1].participantType == 3 call_args = mock_make_request.call_args assert call_args[0][0] == "GET" assert "/api/v4/room/abc/participants" in call_args[0][1] # Default: include_status off → no includeStatus param assert "includeStatus" not in call_args[1].get("params", {}) async def test_talk_list_participants_with_include_status(mocker): """include_status=True forwards includeStatus=1 as a query param.""" mock_response = create_mock_response( status_code=200, json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, ) mock_make_request = mocker.patch.object( TalkClient, "_make_request", return_value=mock_response ) client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") await client.list_participants("abc", include_status=True) params = mock_make_request.call_args[1]["params"] assert params["includeStatus"] == 1