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