gabriel / musehub public
test_musehub_context.py python
574 lines 17.9 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the agent context endpoint (GET /musehub/repos/{repo_id}/context).
2
3 Covers every acceptance criterion:
4 - GET /musehub/repos/{repo_id}/context returns all required sections
5 - Musical state section is present (active_tracks, key, tempo, etc.)
6 - History section includes recent commits
7 - Active PRs section lists open PRs
8 - Open issues section lists open issues
9 - Suggestions section is present
10 - ?depth=brief returns minimal context
11 - ?depth=standard returns moderate context
12 - ?depth=verbose returns full context
13 - ?format=yaml returns valid YAML
14 - Unknown repo returns 404
15 - Missing ref returns 404
16 - Endpoint requires JWT auth
17
18 All tests use fixtures from conftest.py.
19 """
20 from __future__ import annotations
21
22 import pytest
23 import yaml # PyYAML ships no py.typed marker
24 from datetime import datetime, timezone
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.db.musehub_models import (
29 MusehubBranch,
30 MusehubCommit,
31 MusehubIssue,
32 MusehubPullRequest,
33 MusehubRepo,
34 )
35
36
37 # ---------------------------------------------------------------------------
38 # Shared helpers
39 # ---------------------------------------------------------------------------
40
41
42 async def _create_repo(client: AsyncClient, auth_headers: dict[str, str], name: str = "neo-soul") -> str:
43 """Create a repo via the API and return its repo_id."""
44 response = await client.post(
45 "/api/v1/musehub/repos",
46 json={"name": name, "owner": "testuser"},
47 headers=auth_headers,
48 )
49 assert response.status_code == 201
50 repo_id: str = response.json()["repoId"]
51 return repo_id
52
53
54 async def _seed_repo_with_commits(
55 db: AsyncSession,
56 repo_id: str,
57 branch_name: str = "main",
58 num_commits: int = 3,
59 ) -> tuple[str, list[str]]:
60 """Seed a repo with a branch and commits. Returns (branch_id, list_of_commit_ids)."""
61 commit_ids: list[str] = []
62 parent_id: str | None = None
63 ts = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
64
65 import uuid
66 from datetime import timedelta
67
68 for i in range(num_commits):
69 commit_id = str(uuid.uuid4()).replace("-", "")
70 commit = MusehubCommit(
71 commit_id=commit_id,
72 repo_id=repo_id,
73 branch=branch_name,
74 parent_ids=[parent_id] if parent_id else [],
75 message=f"Add layer {i + 1} — bass groove refinement",
76 author="session-agent",
77 timestamp=ts + timedelta(hours=i),
78 )
79 db.add(commit)
80 commit_ids.append(commit_id)
81 parent_id = commit_id
82
83 branch = MusehubBranch(
84 repo_id=repo_id,
85 name=branch_name,
86 head_commit_id=commit_ids[-1],
87 )
88 db.add(branch)
89 await db.flush()
90
91 return branch_name, commit_ids
92
93
94 # ---------------------------------------------------------------------------
95 # test_context_endpoint_returns_all_sections
96 # ---------------------------------------------------------------------------
97
98
99 @pytest.mark.anyio
100 async def test_context_endpoint_returns_all_sections(
101 client: AsyncClient,
102 auth_headers: dict[str, str],
103 db_session: AsyncSession,
104 ) -> None:
105 """GET /musehub/repos/{repo_id}/context returns all required top-level sections."""
106 repo_id = await _create_repo(client, auth_headers)
107 await _seed_repo_with_commits(db_session, repo_id)
108 await db_session.commit()
109
110 response = await client.get(
111 f"/api/v1/musehub/repos/{repo_id}/context",
112 headers=auth_headers,
113 )
114 assert response.status_code == 200
115 body = response.json()
116
117 assert "repoId" in body
118 assert "ref" in body
119 assert "depth" in body
120 assert "musicalState" in body
121 assert "history" in body
122 assert "analysis" in body
123 assert "activePrs" in body
124 assert "openIssues" in body
125 assert "suggestions" in body
126
127 assert body["repoId"] == repo_id
128 assert body["depth"] == "standard"
129
130
131 # ---------------------------------------------------------------------------
132 # test_context_includes_musical_state
133 # ---------------------------------------------------------------------------
134
135
136 @pytest.mark.anyio
137 async def test_context_includes_musical_state(
138 client: AsyncClient,
139 auth_headers: dict[str, str],
140 db_session: AsyncSession,
141 ) -> None:
142 """Musical state section contains expected fields (key, tempo, etc. may be None at MVP)."""
143 repo_id = await _create_repo(client, auth_headers)
144 await _seed_repo_with_commits(db_session, repo_id)
145 await db_session.commit()
146
147 response = await client.get(
148 f"/api/v1/musehub/repos/{repo_id}/context",
149 headers=auth_headers,
150 )
151 assert response.status_code == 200
152 state = response.json()["musicalState"]
153
154 assert "activeTracks" in state
155 assert isinstance(state["activeTracks"], list)
156 # Optional fields present (None until Storpheus integration)
157 assert "key" in state
158 assert "tempoBpm" in state
159 assert "timeSignature" in state
160 assert "form" in state
161 assert "emotion" in state
162
163
164 # ---------------------------------------------------------------------------
165 # test_context_includes_history
166 # ---------------------------------------------------------------------------
167
168
169 @pytest.mark.anyio
170 async def test_context_includes_history(
171 client: AsyncClient,
172 auth_headers: dict[str, str],
173 db_session: AsyncSession,
174 ) -> None:
175 """History section includes recent commits (excluding the head commit)."""
176 repo_id = await _create_repo(client, auth_headers)
177 _, commit_ids = await _seed_repo_with_commits(db_session, repo_id, num_commits=5)
178 await db_session.commit()
179
180 response = await client.get(
181 f"/api/v1/musehub/repos/{repo_id}/context",
182 headers=auth_headers,
183 )
184 assert response.status_code == 200
185 history = response.json()["history"]
186
187 assert isinstance(history, list)
188 # 5 commits seeded → head excluded → at most 4 in history at standard depth
189 assert len(history) <= 10
190 assert len(history) >= 1
191
192 entry = history[0]
193 assert "commitId" in entry
194 assert "message" in entry
195 assert "author" in entry
196 assert "timestamp" in entry
197 assert "activeTracks" in entry
198
199
200 # ---------------------------------------------------------------------------
201 # test_context_includes_active_prs
202 # ---------------------------------------------------------------------------
203
204
205 @pytest.mark.anyio
206 async def test_context_includes_active_prs(
207 client: AsyncClient,
208 auth_headers: dict[str, str],
209 db_session: AsyncSession,
210 ) -> None:
211 """Active PRs section lists open pull requests for the repo."""
212 repo_id = await _create_repo(client, auth_headers)
213 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
214
215 import uuid
216 from datetime import timedelta
217
218 feature_branch = MusehubBranch(
219 repo_id=repo_id,
220 name="feat/tritone-subs",
221 head_commit_id=str(uuid.uuid4()).replace("-", ""),
222 )
223 db_session.add(feature_branch)
224 await db_session.flush()
225
226 pr = MusehubPullRequest(
227 repo_id=repo_id,
228 title="Add tritone substitution in bridge",
229 body="Resolves the harmonic monotony in bars 24-28.",
230 state="open",
231 from_branch="feat/tritone-subs",
232 to_branch="main",
233 )
234 db_session.add(pr)
235 await db_session.commit()
236
237 response = await client.get(
238 f"/api/v1/musehub/repos/{repo_id}/context",
239 headers=auth_headers,
240 )
241 assert response.status_code == 200
242 prs = response.json()["activePrs"]
243
244 assert isinstance(prs, list)
245 assert len(prs) == 1
246 assert prs[0]["title"] == "Add tritone substitution in bridge"
247 assert prs[0]["state"] == "open"
248 assert "prId" in prs[0]
249 assert "fromBranch" in prs[0]
250 assert "toBranch" in prs[0]
251
252
253 # ---------------------------------------------------------------------------
254 # test_context_brief_depth
255 # ---------------------------------------------------------------------------
256
257
258 @pytest.mark.anyio
259 async def test_context_brief_depth(
260 client: AsyncClient,
261 auth_headers: dict[str, str],
262 db_session: AsyncSession,
263 ) -> None:
264 """?depth=brief returns minimal context — at most 3 history entries and 2 suggestions."""
265 repo_id = await _create_repo(client, auth_headers)
266 await _seed_repo_with_commits(db_session, repo_id, num_commits=8)
267 await db_session.commit()
268
269 response = await client.get(
270 f"/api/v1/musehub/repos/{repo_id}/context?depth=brief",
271 headers=auth_headers,
272 )
273 assert response.status_code == 200
274 body = response.json()
275
276 assert body["depth"] == "brief"
277 assert len(body["history"]) <= 3
278 assert len(body["suggestions"]) <= 2
279
280
281 # ---------------------------------------------------------------------------
282 # test_context_standard_depth
283 # ---------------------------------------------------------------------------
284
285
286 @pytest.mark.anyio
287 async def test_context_standard_depth(
288 client: AsyncClient,
289 auth_headers: dict[str, str],
290 db_session: AsyncSession,
291 ) -> None:
292 """?depth=standard (default) returns at most 10 history entries."""
293 repo_id = await _create_repo(client, auth_headers)
294 await _seed_repo_with_commits(db_session, repo_id, num_commits=15)
295 await db_session.commit()
296
297 response = await client.get(
298 f"/api/v1/musehub/repos/{repo_id}/context?depth=standard",
299 headers=auth_headers,
300 )
301 assert response.status_code == 200
302 body = response.json()
303
304 assert body["depth"] == "standard"
305 assert len(body["history"]) <= 10
306
307
308 # ---------------------------------------------------------------------------
309 # test_context_verbose_depth_includes_issue_bodies
310 # ---------------------------------------------------------------------------
311
312
313 @pytest.mark.anyio
314 async def test_context_verbose_depth_includes_issue_bodies(
315 client: AsyncClient,
316 auth_headers: dict[str, str],
317 db_session: AsyncSession,
318 ) -> None:
319 """?depth=verbose includes full issue bodies; brief/standard do not."""
320 repo_id = await _create_repo(client, auth_headers)
321 await _seed_repo_with_commits(db_session, repo_id)
322
323 import uuid
324
325 issue = MusehubIssue(
326 repo_id=repo_id,
327 number=1,
328 title="Add more harmonic tension",
329 body="Consider a tritone substitution in bar 24 to create tension before the resolution.",
330 state="open",
331 labels=["harmonic", "composition"],
332 )
333 db_session.add(issue)
334 await db_session.commit()
335
336 # brief: body should be empty string
337 brief_resp = await client.get(
338 f"/api/v1/musehub/repos/{repo_id}/context?depth=brief",
339 headers=auth_headers,
340 )
341 assert brief_resp.status_code == 200
342 brief_issues = brief_resp.json()["openIssues"]
343 assert len(brief_issues) == 1
344 assert brief_issues[0]["body"] == ""
345
346 # verbose: body should be included
347 verbose_resp = await client.get(
348 f"/api/v1/musehub/repos/{repo_id}/context?depth=verbose",
349 headers=auth_headers,
350 )
351 assert verbose_resp.status_code == 200
352 verbose_issues = verbose_resp.json()["openIssues"]
353 assert len(verbose_issues) == 1
354 assert "tritone substitution" in verbose_issues[0]["body"]
355
356
357 # ---------------------------------------------------------------------------
358 # test_context_yaml_format
359 # ---------------------------------------------------------------------------
360
361
362 @pytest.mark.anyio
363 async def test_context_yaml_format(
364 client: AsyncClient,
365 auth_headers: dict[str, str],
366 db_session: AsyncSession,
367 ) -> None:
368 """?format=yaml returns valid YAML with the same structure as JSON."""
369 repo_id = await _create_repo(client, auth_headers)
370 await _seed_repo_with_commits(db_session, repo_id)
371 await db_session.commit()
372
373 response = await client.get(
374 f"/api/v1/musehub/repos/{repo_id}/context?format=yaml",
375 headers=auth_headers,
376 )
377 assert response.status_code == 200
378 assert "yaml" in response.headers["content-type"]
379
380 parsed = yaml.safe_load(response.text)
381 assert isinstance(parsed, dict)
382 assert "repoId" in parsed
383 assert "musicalState" in parsed
384 assert "history" in parsed
385 assert "analysis" in parsed
386
387
388 # ---------------------------------------------------------------------------
389 # test_context_unknown_repo_404
390 # ---------------------------------------------------------------------------
391
392
393 @pytest.mark.anyio
394 async def test_context_unknown_repo_404(
395 client: AsyncClient,
396 auth_headers: dict[str, str],
397 ) -> None:
398 """GET /musehub/repos/{unknown_id}/context returns 404 for a non-existent repo."""
399 response = await client.get(
400 "/api/v1/musehub/repos/nonexistent-repo-id/context",
401 headers=auth_headers,
402 )
403 assert response.status_code == 404
404
405
406 # ---------------------------------------------------------------------------
407 # test_context_ref_not_found_404
408 # ---------------------------------------------------------------------------
409
410
411 @pytest.mark.anyio
412 async def test_context_ref_not_found_404(
413 client: AsyncClient,
414 auth_headers: dict[str, str],
415 db_session: AsyncSession,
416 ) -> None:
417 """GET .../context?ref=nonexistent returns 404 when the ref has no commits."""
418 repo_id = await _create_repo(client, auth_headers)
419 await db_session.commit()
420
421 response = await client.get(
422 f"/api/v1/musehub/repos/{repo_id}/context?ref=nonexistent-branch",
423 headers=auth_headers,
424 )
425 assert response.status_code == 404
426
427
428 # ---------------------------------------------------------------------------
429 # test_context_requires_auth
430 # ---------------------------------------------------------------------------
431
432
433 @pytest.mark.anyio
434 async def test_context_nonexistent_repo_returns_404_without_auth(
435 client: AsyncClient,
436 db_session: AsyncSession,
437 ) -> None:
438 """GET /musehub/repos/{repo_id}/context returns 404 for a non-existent repo without auth.
439
440 Context endpoint uses optional_token — auth check is visibility-based,
441 so a missing repo returns 404 before the auth check fires.
442 """
443 response = await client.get(
444 "/api/v1/musehub/repos/non-existent-repo-id/context",
445 )
446 assert response.status_code == 404
447
448
449 # ---------------------------------------------------------------------------
450 # test_context_default_ref_resolves_to_latest_commit
451 # ---------------------------------------------------------------------------
452
453
454 @pytest.mark.anyio
455 async def test_context_default_ref_resolves_to_latest_commit(
456 client: AsyncClient,
457 auth_headers: dict[str, str],
458 db_session: AsyncSession,
459 ) -> None:
460 """?ref=HEAD (default) resolves to the latest commit and returns a valid ref in response."""
461 repo_id = await _create_repo(client, auth_headers)
462 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
463 await db_session.commit()
464
465 response = await client.get(
466 f"/api/v1/musehub/repos/{repo_id}/context",
467 headers=auth_headers,
468 )
469 assert response.status_code == 200
470 body = response.json()
471
472 # ref should resolve to a branch name or commit id (not literally "HEAD")
473 assert body["ref"] != ""
474
475
476 # ---------------------------------------------------------------------------
477 # test_context_branch_ref_resolution
478 # ---------------------------------------------------------------------------
479
480
481 @pytest.mark.anyio
482 async def test_context_branch_ref_resolution(
483 client: AsyncClient,
484 auth_headers: dict[str, str],
485 db_session: AsyncSession,
486 ) -> None:
487 """?ref=<branch_name> resolves the branch head commit."""
488 repo_id = await _create_repo(client, auth_headers)
489 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
490 await db_session.commit()
491
492 response = await client.get(
493 f"/api/v1/musehub/repos/{repo_id}/context?ref=main",
494 headers=auth_headers,
495 )
496 assert response.status_code == 200
497 body = response.json()
498 assert body["ref"] == "main"
499
500
501 # ---------------------------------------------------------------------------
502 # test_context_suggestions_generated
503 # ---------------------------------------------------------------------------
504
505
506 @pytest.mark.anyio
507 async def test_context_suggestions_generated(
508 client: AsyncClient,
509 auth_headers: dict[str, str],
510 db_session: AsyncSession,
511 ) -> None:
512 """Suggestions are generated and returned as a list of strings."""
513 repo_id = await _create_repo(client, auth_headers)
514 await _seed_repo_with_commits(db_session, repo_id)
515 await db_session.commit()
516
517 response = await client.get(
518 f"/api/v1/musehub/repos/{repo_id}/context",
519 headers=auth_headers,
520 )
521 assert response.status_code == 200
522 suggestions = response.json()["suggestions"]
523
524 assert isinstance(suggestions, list)
525 assert all(isinstance(s, str) for s in suggestions)
526 # At least one suggestion since no key/tempo detected (stubs)
527 assert len(suggestions) >= 1
528
529
530 # ---------------------------------------------------------------------------
531 # test_context_open_issues_excluded_when_closed
532 # ---------------------------------------------------------------------------
533
534
535 @pytest.mark.anyio
536 async def test_context_open_issues_excluded_when_closed(
537 client: AsyncClient,
538 auth_headers: dict[str, str],
539 db_session: AsyncSession,
540 ) -> None:
541 """Closed issues do not appear in the open_issues section."""
542 repo_id = await _create_repo(client, auth_headers)
543 await _seed_repo_with_commits(db_session, repo_id)
544
545 closed_issue = MusehubIssue(
546 repo_id=repo_id,
547 number=1,
548 title="Closed: fix the bridge",
549 body="Already fixed.",
550 state="closed",
551 labels=[],
552 )
553 open_issue = MusehubIssue(
554 repo_id=repo_id,
555 number=2,
556 title="Add swing feel to verse",
557 body="",
558 state="open",
559 labels=["groove"],
560 )
561 db_session.add(closed_issue)
562 db_session.add(open_issue)
563 await db_session.commit()
564
565 response = await client.get(
566 f"/api/v1/musehub/repos/{repo_id}/context",
567 headers=auth_headers,
568 )
569 assert response.status_code == 200
570 issues = response.json()["openIssues"]
571
572 assert len(issues) == 1
573 assert issues[0]["title"] == "Add swing feel to verse"
574 assert issues[0]["number"] == 2