22
33import contextlib
44import json
5- from collections .abc import AsyncGenerator
5+ from collections .abc import AsyncGenerator , AsyncIterator
66from typing import Any
77
8+ import niquests
89import pytest
910from mcp .server .fastmcp .exceptions import ToolError
1011
1112from nc_mcp_server .client import NextcloudClient , NextcloudError
1213from nc_mcp_server .config import Config
14+ from nc_mcp_server .state import get_client , get_config , set_state
1315
1416from .conftest import McpTestHelper
1517
1618pytestmark = pytest .mark .integration
1719
1820CIRCLE_TEST_USER = "mcp-circle-test-user"
21+ CIRCLE_TEST_PWD = "mcp-Circle-Test-PWD-9X!"
22+
23+
24+ @pytest .fixture (scope = "session" )
25+ def _circles_available (_cleanup_config : Config ) -> bool :
26+ """Probe whether the Circles app is enabled on the target Nextcloud.
27+
28+ Sync-only check so we can return a plain bool at session scope without
29+ fighting pytest-asyncio's per-test event loop.
30+ """
31+ try :
32+ resp = niquests .get (
33+ f"{ _cleanup_config .nextcloud_url } /ocs/v2.php/apps/circles/circles" ,
34+ auth = (_cleanup_config .user , _cleanup_config .password ),
35+ headers = {"OCS-APIRequest" : "true" , "Accept" : "application/json" },
36+ timeout = 5 ,
37+ )
38+ except (OSError , niquests .exceptions .RequestException ):
39+ return False
40+ return resp .ok
41+
42+
43+ @pytest .fixture (autouse = True )
44+ def _skip_if_no_circles (_circles_available : bool ) -> None :
45+ """Skip every Circles test when the app isn't enabled on the target instance."""
46+ if not _circles_available :
47+ pytest .skip ("Circles app is not enabled on this Nextcloud instance" )
1948
2049
2150@pytest .fixture
@@ -25,14 +54,41 @@ async def circle_peer(nc_config: Config) -> AsyncGenerator[str]:
2554 with contextlib .suppress (Exception ):
2655 await client .ocs_post (
2756 "cloud/users" ,
28- data = {"userid" : CIRCLE_TEST_USER , "password" : "mcp-Circle-Test-PWD-9X!" },
57+ data = {"userid" : CIRCLE_TEST_USER , "password" : CIRCLE_TEST_PWD },
2958 )
3059 yield CIRCLE_TEST_USER
3160 with contextlib .suppress (Exception ):
3261 await client .ocs_delete (f"cloud/users/{ CIRCLE_TEST_USER } " )
3362 await client .close ()
3463
3564
65+ @contextlib .asynccontextmanager
66+ async def _as_peer (user_id : str , password : str ) -> AsyncIterator [None ]:
67+ """Temporarily swap the global state so tool calls authenticate as the given user.
68+
69+ Restores the prior state on exit. Tools (`create_server`, `get_client`) read the
70+ client from module-global state, so swapping it lets the existing McpTestHelper
71+ exercise real tools under a different user's credentials without needing a
72+ second server instance.
73+ """
74+ admin_client = get_client ()
75+ admin_config = get_config ()
76+ peer_config = Config (
77+ nextcloud_url = admin_config .nextcloud_url ,
78+ user = user_id ,
79+ password = password ,
80+ permission_level = admin_config .permission_level ,
81+ )
82+ peer_config .validate ()
83+ peer_client = NextcloudClient (peer_config )
84+ set_state (peer_client , peer_config )
85+ try :
86+ yield
87+ finally :
88+ set_state (admin_client , admin_config )
89+ await peer_client .close ()
90+
91+
3692async def _make_circle (nc_mcp : McpTestHelper , name : str ) -> dict [str , Any ]:
3793 """Create a circle and return its dict."""
3894 created : dict [str , Any ] = json .loads (await nc_mcp .call ("create_circle" , name = name ))
@@ -52,7 +108,7 @@ async def test_list_pagination(self, nc_mcp: McpTestHelper) -> None:
52108 for i in range (3 ):
53109 await _make_circle (nc_mcp , f"mcp-test-circle-page-{ i } " )
54110 circles : list [dict [str , Any ]] = json .loads (await nc_mcp .call ("list_circles" , limit = 2 ))
55- assert len (circles ) < = 2
111+ assert len (circles ) = = 2
56112
57113
58114class TestCircleLifecycle :
@@ -187,18 +243,26 @@ async def test_remove_member(self, nc_mcp: McpTestHelper, circle_peer: str) -> N
187243
188244class TestJoinLeave :
189245 @pytest .mark .asyncio
190- async def test_leave_as_non_owner (self , nc_mcp : McpTestHelper , circle_peer : str ) -> None :
191- """Admin adds peer then removes peer — membership disappears."""
192- admin = nc_mcp
193- circle = json .loads (await admin .call ("create_circle" , name = "mcp-test-circle-leave" ))
194- peer_member = json .loads (await admin .call ("add_circle_member" , circle_id = circle ["id" ], user_id = circle_peer ))
195- # Admin removes peer (equivalent to peer leaving, same effect)
196- await admin .call (
197- "remove_circle_member" ,
198- circle_id = circle ["id" ],
199- member_id = peer_member ["id" ],
200- )
201- members : list [dict [str , Any ]] = json .loads (await admin .call ("list_circle_members" , circle_id = circle ["id" ]))
246+ async def test_join_open_circle (self , nc_mcp : McpTestHelper , circle_peer : str ) -> None :
247+ """Admin creates an OPEN circle; peer calls join_circle and appears in members."""
248+ circle = await _make_circle (nc_mcp , "mcp-test-circle-open-join" )
249+ # 24 = VISIBLE(8) | OPEN(16) — required for peer to join via join_circle
250+ await nc_mcp .call ("update_circle_config" , circle_id = circle ["id" ], config = 24 )
251+ async with _as_peer (circle_peer , CIRCLE_TEST_PWD ):
252+ joined = json .loads (await nc_mcp .call ("join_circle" , circle_id = circle ["id" ]))
253+ assert joined ["userId" ] == circle_peer
254+ assert joined ["status" ] == "Member"
255+ members : list [dict [str , Any ]] = json .loads (await nc_mcp .call ("list_circle_members" , circle_id = circle ["id" ]))
256+ assert any (m .get ("userId" ) == circle_peer for m in members )
257+
258+ @pytest .mark .asyncio
259+ async def test_leave_as_member (self , nc_mcp : McpTestHelper , circle_peer : str ) -> None :
260+ """Admin adds peer; peer calls leave_circle; peer is gone from member list."""
261+ circle = await _make_circle (nc_mcp , "mcp-test-circle-leave" )
262+ await nc_mcp .call ("add_circle_member" , circle_id = circle ["id" ], user_id = circle_peer )
263+ async with _as_peer (circle_peer , CIRCLE_TEST_PWD ):
264+ await nc_mcp .call ("leave_circle" , circle_id = circle ["id" ])
265+ members : list [dict [str , Any ]] = json .loads (await nc_mcp .call ("list_circle_members" , circle_id = circle ["id" ]))
202266 assert not any (m .get ("userId" ) == circle_peer for m in members )
203267
204268
0 commit comments