gabriel / musehub public
test_musehub_api_contracts.py python
326 lines 10.5 KB
Raw
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32 fix: fall back to DB ancestry check when mpack-only fast-fo… Sonnet 4.6 patch 6 days ago
1 """Deep API contract tests for core MuseHub endpoints.
2
3 This file addresses the shallow-assertion gap: many existing tests only
4 assert on status codes. Here we verify complete response bodies, field
5 types, and envelope structure for the most critical endpoints — repos,
6 commits, branches, issues, and explore.
7
8 Uses ``tests.factories`` for clean, declarative data setup.
9 All tests are module-level async functions (not class-based) to ensure
10 pytest-asyncio fixture injection works correctly.
11 """
12 from __future__ import annotations
13
14 import pytest
15 from httpx import AsyncClient
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from tests.factories import create_repo, create_branch, create_commit
19 from musehub.types.json_types import StrDict
20
21
22 # ---------------------------------------------------------------------------
23 # Repo CRUD response contracts
24 # ---------------------------------------------------------------------------
25
26 async def test_create_repo_response_shape(
27 client: AsyncClient,
28 auth_headers: StrDict,
29 db_session: AsyncSession,
30 ) -> None:
31 """POST /repos returns all required fields with correct types."""
32 resp = await client.post(
33 "/api/repos",
34 json={"name": "contract-test", "owner": "tester", "visibility": "public"},
35 headers=auth_headers,
36 )
37 assert resp.status_code == 201
38 body = resp.json()
39
40 for key in ("repoId", "name", "owner", "slug", "visibility", "ownerUserId",
41 "cloneUrl", "createdAt"):
42 assert key in body, f"Missing field: {key}"
43 assert isinstance(body[key], str), f"Field {key} should be a string"
44
45 assert body["name"] == "contract-test"
46 assert body["owner"] == "tester"
47 assert body["visibility"] == "public"
48 assert body["slug"] == "contract-test"
49 assert isinstance(body["tags"], list)
50
51
52 async def test_get_repo_response_shape(
53 client: AsyncClient,
54 auth_headers: StrDict,
55 db_session: AsyncSession,
56 ) -> None:
57 """GET /repos/{id} returns all expected fields."""
58 create = await client.post(
59 "/api/repos",
60 json={"name": "get-shape-test", "owner": "tester"},
61 headers=auth_headers,
62 )
63 repo_id = create.json()["repoId"]
64
65 resp = await client.get(f"/api/repos/{repo_id}", headers=auth_headers)
66 assert resp.status_code == 200
67 body = resp.json()
68
69 assert body["repoId"] == repo_id
70 assert body["name"] == "get-shape-test"
71 assert isinstance(body["tags"], list)
72 assert isinstance(body["createdAt"], str)
73
74
75 async def test_update_repo_settings_returns_updated_fields(
76 client: AsyncClient,
77 auth_headers: StrDict,
78 db_session: AsyncSession,
79 ) -> None:
80 """PATCH /repos/{id}/settings returns the updated repo fields."""
81 create = await client.post(
82 "/api/repos",
83 json={"name": "patch-test-repo", "owner": "testuser"},
84 headers=auth_headers,
85 )
86 repo_id = create.json()["repoId"]
87
88 patch_resp = await client.patch(
89 f"/api/repos/{repo_id}/settings",
90 json={"description": "Updated description", "visibility": "public"},
91 headers=auth_headers,
92 )
93 assert patch_resp.status_code == 200
94 body = patch_resp.json()
95 assert body["description"] == "Updated description"
96 assert body["visibility"] == "public"
97 assert "name" in body # RepoSettingsResponse fields
98
99
100 # ---------------------------------------------------------------------------
101 # Branch response contracts
102 # ---------------------------------------------------------------------------
103
104 async def test_list_branches_envelope(
105 client: AsyncClient,
106 auth_headers: StrDict,
107 db_session: AsyncSession,
108 ) -> None:
109 """GET /repos/{id}/branches returns a 'branches' list with name fields."""
110 repo = await create_repo(db_session, owner="brancher", slug="branch-contract")
111 await create_branch(db_session, repo_id=str(repo.repo_id), name="main")
112 await create_branch(db_session, repo_id=str(repo.repo_id), name="feature-x")
113
114 resp = await client.get(
115 f"/api/repos/{repo.repo_id}/branches",
116 headers=auth_headers,
117 )
118 assert resp.status_code == 200
119 body = resp.json()
120
121 assert "branches" in body
122 assert isinstance(body["branches"], list)
123 assert len(body["branches"]) == 2
124 for branch in body["branches"]:
125 assert "name" in branch
126 assert isinstance(branch["name"], str)
127
128
129 async def test_branch_names_are_correct(
130 client: AsyncClient,
131 auth_headers: StrDict,
132 db_session: AsyncSession,
133 ) -> None:
134 """Branch names returned by the API match what was inserted."""
135 repo = await create_repo(db_session, owner="brancher2", slug="branch-names")
136 await create_branch(db_session, repo_id=str(repo.repo_id), name="develop")
137
138 resp = await client.get(
139 f"/api/repos/{repo.repo_id}/branches",
140 headers=auth_headers,
141 )
142 names = [b["name"] for b in resp.json()["branches"]]
143 assert "develop" in names
144
145
146 # ---------------------------------------------------------------------------
147 # Commit response contracts
148 # ---------------------------------------------------------------------------
149
150 async def test_list_commits_envelope(
151 client: AsyncClient,
152 auth_headers: StrDict,
153 db_session: AsyncSession,
154 ) -> None:
155 """GET /repos/{id}/commits returns a 'commits' list envelope."""
156 repo = await create_repo(db_session, owner="committer", slug="commit-contract")
157 await create_commit(db_session, str(repo.repo_id), message="init: first commit")
158 await create_commit(db_session, str(repo.repo_id), message="feat: second commit")
159
160 resp = await client.get(
161 f"/api/repos/{repo.repo_id}/commits",
162 headers=auth_headers,
163 )
164 assert resp.status_code == 200
165 body = resp.json()
166
167 assert "commits" in body
168 assert isinstance(body["commits"], list)
169 assert len(body["commits"]) == 2
170
171
172 async def test_commit_fields(
173 client: AsyncClient,
174 auth_headers: StrDict,
175 db_session: AsyncSession,
176 ) -> None:
177 """Each commit object has message, author, branch, commitId, timestamp, parentIds."""
178 repo = await create_repo(db_session, owner="committer2", slug="commit-fields")
179 await create_commit(
180 db_session, str(repo.repo_id),
181 message="feat: piano track",
182 author="mozart",
183 branch="main",
184 )
185
186 resp = await client.get(
187 f"/api/repos/{repo.repo_id}/commits",
188 headers=auth_headers,
189 )
190 commits = resp.json()["commits"]
191 assert len(commits) == 1
192 c = commits[0]
193
194 assert c["message"] == "feat: piano track"
195 assert c["author"] == "mozart"
196 assert c["branch"] == "main"
197 assert "commitId" in c
198 assert "timestamp" in c
199 assert isinstance(c["parentIds"], list)
200
201
202 # ---------------------------------------------------------------------------
203 # Issue response contracts
204 # ---------------------------------------------------------------------------
205
206 async def test_create_issue_response_shape(
207 client: AsyncClient,
208 auth_headers: StrDict,
209 db_session: AsyncSession,
210 ) -> None:
211 """POST /repos/{id}/issues returns title, body, status, number, createdAt."""
212 create_repo_resp = await client.post(
213 "/api/repos",
214 json={"name": "issue-contract-repo", "owner": "testuser"},
215 headers=auth_headers,
216 )
217 repo_id = create_repo_resp.json()["repoId"]
218
219 resp = await client.post(
220 f"/api/repos/{repo_id}/issues",
221 json={"title": "Bug: tempo drift", "body": "The tempo drifts by 3 BPM"},
222 headers=auth_headers,
223 )
224 assert resp.status_code == 201
225 body = resp.json()
226
227 assert body["title"] == "Bug: tempo drift"
228 assert body["body"] == "The tempo drifts by 3 BPM"
229 assert body["state"] == "open"
230 assert "number" in body
231 assert isinstance(body["number"], int)
232 assert "createdAt" in body
233
234
235 async def test_list_issues_returns_open_issues(
236 client: AsyncClient,
237 auth_headers: StrDict,
238 db_session: AsyncSession,
239 ) -> None:
240 """GET /repos/{id}/issues returns issues envelope with status=open."""
241 create_repo_resp = await client.post(
242 "/api/repos",
243 json={"name": "issue-list-contract", "owner": "testuser"},
244 headers=auth_headers,
245 )
246 repo_id = create_repo_resp.json()["repoId"]
247
248 for i in range(3):
249 await client.post(
250 f"/api/repos/{repo_id}/issues",
251 json={"title": f"Issue {i}"},
252 headers=auth_headers,
253 )
254
255 resp = await client.get(
256 f"/api/repos/{repo_id}/issues",
257 headers=auth_headers,
258 )
259 assert resp.status_code == 200
260 body = resp.json()
261
262 assert "issues" in body
263 assert len(body["issues"]) == 3
264 for issue in body["issues"]:
265 assert issue["state"] == "open"
266 assert "title" in issue
267 assert "number" in issue
268
269
270 async def test_close_issue_changes_status(
271 client: AsyncClient,
272 auth_headers: StrDict,
273 db_session: AsyncSession,
274 ) -> None:
275 """POST /repos/{id}/issues/{n}/close sets status to 'closed'."""
276 create_repo_resp = await client.post(
277 "/api/repos",
278 json={"name": "close-issue-contract", "owner": "testuser"},
279 headers=auth_headers,
280 )
281 repo_id = create_repo_resp.json()["repoId"]
282
283 issue_resp = await client.post(
284 f"/api/repos/{repo_id}/issues",
285 json={"title": "Close me"},
286 headers=auth_headers,
287 )
288 number = issue_resp.json()["number"]
289
290 close_resp = await client.post(
291 f"/api/repos/{repo_id}/issues/{number}/close",
292 headers=auth_headers,
293 )
294 assert close_resp.status_code == 200
295 assert close_resp.json()["state"] == "closed"
296
297
298 # ---------------------------------------------------------------------------
299 # Explore / discover
300 # ---------------------------------------------------------------------------
301
302 async def test_explore_returns_public_repos(
303 client: AsyncClient,
304 auth_headers: StrDict,
305 db_session: AsyncSession,
306 ) -> None:
307 """GET /repos/explore returns public repos and excludes private ones."""
308 await client.post(
309 "/api/repos",
310 json={"name": "explore-public", "owner": "explorer", "visibility": "public"},
311 headers=auth_headers,
312 )
313 await client.post(
314 "/api/repos",
315 json={"name": "explore-private", "owner": "explorer", "visibility": "private"},
316 headers=auth_headers,
317 )
318
319 resp = await client.get("/api/discover/repos")
320 assert resp.status_code == 200
321 body = resp.json()
322 repos = body if isinstance(body, list) else body.get("repos", body.get("items", []))
323
324 slugs = [r.get("slug", "") for r in repos]
325 assert "explore-public" in slugs
326 assert "explore-private" not in slugs
File History 1 commit
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32 fix: fall back to DB ancestry check when mpack-only fast-fo… Sonnet 4.6 patch 6 days ago