gabriel / musehub public
test_musehub_collaborators.py python
266 lines 9.5 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MuseHub collaborators management endpoints.
2
3 Covers the acceptance criteria:
4 - GET /repos/{repo_id}/collaborators returns collaborator list
5 - POST /repos/{repo_id}/collaborators invites a collaborator (owner/admin+)
6 - PUT /repos/{repo_id}/collaborators/{user_id}/permission updates permission
7 - DELETE /repos/{repo_id}/collaborators/{user_id} removes collaborator
8 - GET /repos/{repo_id}/collaborators/{user_id}/permission checks presence
9 - Owner cannot be removed as a collaborator
10 - Only admin+ (or owner) may mutate collaborators
11 - Duplicate invite returns 409
12 """
13 from __future__ import annotations
14
15 import pytest
16 from httpx import AsyncClient
17
18 # ── Constants ─────────────────────────────────────────────────────────────────
19
20 _COLLABORATOR_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
21
22
23 # ── Helpers ───────────────────────────────────────────────────────────────────
24
25
26 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "collab-test-repo") -> str:
27 """Create a repo via the API and return its repo_id."""
28 response = await client.post(
29 "/api/v1/repos",
30 json={"name": name, "owner": "testuser"},
31 headers=auth_headers,
32 )
33 assert response.status_code == 201, response.text
34 repo_id: str = response.json()["repoId"]
35 return repo_id
36
37
38 async def _invite_collaborator(
39 client: AsyncClient,
40 auth_headers: dict[str, str],
41 repo_id: str,
42 user_id: str = _COLLABORATOR_ID,
43 permission: str = "write",
44 ) -> dict[str, object]:
45 """Invite a collaborator via the API."""
46 response = await client.post(
47 f"/api/v1/repos/{repo_id}/collaborators",
48 json={"user_id": user_id, "permission": permission},
49 headers=auth_headers,
50 )
51 assert response.status_code == 201, response.text
52 data: dict[str, object] = response.json()
53 return data
54
55
56 # ── POST /collaborators ───────────────────────────────────────────────────────
57
58
59 @pytest.mark.anyio
60 async def test_invite_collaborator_returns_201(
61 client: AsyncClient,
62 auth_headers: dict[str, str],
63 ) -> None:
64 """Owner can invite a collaborator; response contains all required fields."""
65 repo_id = await _create_repo(client, auth_headers, "invite-201-repo")
66 data = await _invite_collaborator(client, auth_headers, repo_id)
67
68 assert data["userId"] == _COLLABORATOR_ID
69 assert data["repoId"] == repo_id
70 assert data["permission"] == "write"
71 assert "collaboratorId" in data
72
73
74 @pytest.mark.anyio
75 async def test_invite_collaborator_duplicate_returns_409(
76 client: AsyncClient,
77 auth_headers: dict[str, str],
78 ) -> None:
79 """Inviting the same user twice returns 409 Conflict."""
80 repo_id = await _create_repo(client, auth_headers, "invite-dup-repo")
81 await _invite_collaborator(client, auth_headers, repo_id)
82
83 response = await client.post(
84 f"/api/v1/repos/{repo_id}/collaborators",
85 json={"user_id": _COLLABORATOR_ID, "permission": "read"},
86 headers=auth_headers,
87 )
88 assert response.status_code == 409
89
90
91 @pytest.mark.anyio
92 async def test_invite_collaborator_unknown_repo_returns_404(
93 client: AsyncClient,
94 auth_headers: dict[str, str],
95 ) -> None:
96 """Inviting a collaborator to a non-existent repo returns 404."""
97 response = await client.post(
98 "/api/v1/repos/nonexistent-repo-id/collaborators",
99 json={"user_id": _COLLABORATOR_ID, "permission": "read"},
100 headers=auth_headers,
101 )
102 assert response.status_code == 404
103
104
105 @pytest.mark.anyio
106 async def test_invite_collaborator_requires_auth(
107 client: AsyncClient,
108 ) -> None:
109 """POST /collaborators returns 401 without a Bearer token."""
110 response = await client.post(
111 "/api/v1/repos/some-repo/collaborators",
112 json={"user_id": _COLLABORATOR_ID, "permission": "read"},
113 )
114 assert response.status_code == 401
115
116
117 # ── GET /collaborators ────────────────────────────────────────────────────────
118
119
120 @pytest.mark.anyio
121 async def test_list_collaborators_empty(
122 client: AsyncClient,
123 auth_headers: dict[str, str],
124 ) -> None:
125 """GET /collaborators returns empty list for a repo with no collaborators."""
126 repo_id = await _create_repo(client, auth_headers, "list-empty-repo")
127 response = await client.get(
128 f"/api/v1/repos/{repo_id}/collaborators",
129 headers=auth_headers,
130 )
131 assert response.status_code == 200
132 body = response.json()
133 assert body["total"] == 0
134 assert body["collaborators"] == []
135
136
137 @pytest.mark.anyio
138 async def test_list_collaborators_after_invite(
139 client: AsyncClient,
140 auth_headers: dict[str, str],
141 ) -> None:
142 """GET /collaborators returns the invited collaborator after POST."""
143 repo_id = await _create_repo(client, auth_headers, "list-after-invite-repo")
144 await _invite_collaborator(client, auth_headers, repo_id)
145
146 response = await client.get(
147 f"/api/v1/repos/{repo_id}/collaborators",
148 headers=auth_headers,
149 )
150 assert response.status_code == 200
151 body = response.json()
152 assert body["total"] == 1
153 assert body["collaborators"][0]["userId"] == _COLLABORATOR_ID
154
155
156 # ── GET /collaborators/{user_id}/permission ───────────────────────────────────
157
158
159 @pytest.mark.anyio
160 async def test_check_permission_not_collaborator(
161 client: AsyncClient,
162 auth_headers: dict[str, str],
163 ) -> None:
164 """Permission check returns 404 for a non-member user (access-check semantics)."""
165 repo_id = await _create_repo(client, auth_headers, "perm-check-not-member-repo")
166 response = await client.get(
167 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}/permission",
168 headers=auth_headers,
169 )
170 assert response.status_code == 404
171 assert _COLLABORATOR_ID in response.json()["detail"]
172
173
174 @pytest.mark.anyio
175 async def test_check_permission_is_collaborator(
176 client: AsyncClient,
177 auth_headers: dict[str, str],
178 ) -> None:
179 """Permission check returns username and permission level after invite."""
180 repo_id = await _create_repo(client, auth_headers, "perm-check-member-repo")
181 await _invite_collaborator(client, auth_headers, repo_id, permission="admin")
182
183 response = await client.get(
184 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}/permission",
185 headers=auth_headers,
186 )
187 assert response.status_code == 200
188 body = response.json()
189 assert body["username"] == _COLLABORATOR_ID
190 assert body["permission"] == "admin"
191
192
193 # ── PUT /collaborators/{user_id}/permission ───────────────────────────────────
194
195
196 @pytest.mark.anyio
197 async def test_update_permission_success(
198 client: AsyncClient,
199 auth_headers: dict[str, str],
200 ) -> None:
201 """Owner can update a collaborator's permission level."""
202 repo_id = await _create_repo(client, auth_headers, "update-perm-repo")
203 await _invite_collaborator(client, auth_headers, repo_id, permission="read")
204
205 response = await client.put(
206 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}/permission",
207 json={"permission": "admin"},
208 headers=auth_headers,
209 )
210 assert response.status_code == 200
211 body = response.json()
212 assert body["permission"] == "admin"
213
214
215 @pytest.mark.anyio
216 async def test_update_permission_not_found_returns_404(
217 client: AsyncClient,
218 auth_headers: dict[str, str],
219 ) -> None:
220 """Updating permission for a non-collaborator returns 404."""
221 repo_id = await _create_repo(client, auth_headers, "update-perm-404-repo")
222 response = await client.put(
223 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}/permission",
224 json={"permission": "admin"},
225 headers=auth_headers,
226 )
227 assert response.status_code == 404
228
229
230 # ── DELETE /collaborators/{user_id} ──────────────────────────────────────────
231
232
233 @pytest.mark.anyio
234 async def test_remove_collaborator_success(
235 client: AsyncClient,
236 auth_headers: dict[str, str],
237 ) -> None:
238 """Owner can remove a collaborator; subsequent list shows 0 collaborators."""
239 repo_id = await _create_repo(client, auth_headers, "remove-collab-repo")
240 await _invite_collaborator(client, auth_headers, repo_id)
241
242 response = await client.delete(
243 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}",
244 headers=auth_headers,
245 )
246 assert response.status_code == 204
247
248 list_response = await client.get(
249 f"/api/v1/repos/{repo_id}/collaborators",
250 headers=auth_headers,
251 )
252 assert list_response.json()["total"] == 0
253
254
255 @pytest.mark.anyio
256 async def test_remove_collaborator_not_found_returns_404(
257 client: AsyncClient,
258 auth_headers: dict[str, str],
259 ) -> None:
260 """Removing a non-collaborator returns 404."""
261 repo_id = await _create_repo(client, auth_headers, "remove-404-repo")
262 response = await client.delete(
263 f"/api/v1/repos/{repo_id}/collaborators/{_COLLABORATOR_ID}",
264 headers=auth_headers,
265 )
266 assert response.status_code == 404