gabriel / musehub public
test_musehub_context.py python
555 lines 17.3 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 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.types.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 async def test_context_endpoint_returns_all_sections(
101 client: AsyncClient,
102 auth_headers: StrDict,
103 db_session: AsyncSession,
104 ) -> None:
105 """GET /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/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 "activeProposals" 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 async def test_context_includes_musical_state(
137 client: AsyncClient,
138 auth_headers: StrDict,
139 db_session: AsyncSession,
140 ) -> None:
141 """Musical state section contains expected fields (key, tempo, etc. may be None at MVP)."""
142 repo_id = await _create_repo(client, auth_headers)
143 await _seed_repo_with_commits(db_session, repo_id)
144 await db_session.commit()
145
146 response = await client.get(
147 f"/api/repos/{repo_id}/context",
148 headers=auth_headers,
149 )
150 assert response.status_code == 200
151 state = response.json()["musicalState"]
152
153 assert "activeTracks" in state
154 assert isinstance(state["activeTracks"], list)
155
156
157 # ---------------------------------------------------------------------------
158 # test_context_includes_history
159 # ---------------------------------------------------------------------------
160
161
162 async def test_context_includes_history(
163 client: AsyncClient,
164 auth_headers: StrDict,
165 db_session: AsyncSession,
166 ) -> None:
167 """History section includes recent commits (excluding the head commit)."""
168 repo_id = await _create_repo(client, auth_headers)
169 _, commit_ids = await _seed_repo_with_commits(db_session, repo_id, num_commits=5)
170 await db_session.commit()
171
172 response = await client.get(
173 f"/api/repos/{repo_id}/context",
174 headers=auth_headers,
175 )
176 assert response.status_code == 200
177 history = response.json()["history"]
178
179 assert isinstance(history, list)
180 # 5 commits seeded → head excluded → at most 4 in history at standard depth
181 assert len(history) <= 10
182 assert len(history) >= 1
183
184 entry = history[0]
185 assert "commitId" in entry
186 assert "message" in entry
187 assert "author" in entry
188 assert "timestamp" in entry
189 assert "activeTracks" in entry
190
191
192 # ---------------------------------------------------------------------------
193 # test_context_includes_active_proposals
194 # ---------------------------------------------------------------------------
195
196
197 async def test_context_includes_active_proposals(
198 client: AsyncClient,
199 auth_headers: StrDict,
200 db_session: AsyncSession,
201 ) -> None:
202 """Active proposals section lists open proposals for the repo."""
203 repo_id = await _create_repo(client, auth_headers)
204 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
205
206 import uuid
207 from datetime import timedelta
208
209 feature_branch = MusehubBranch(
210 repo_id=repo_id,
211 name="feat/tritone-subs",
212 head_commit_id=str(uuid.uuid4()).replace("-", ""),
213 )
214 db_session.add(feature_branch)
215 await db_session.flush()
216
217 proposal = MusehubProposal(
218 repo_id=repo_id,
219 proposal_number=1,
220 title="Add tritone substitution in bridge",
221 body="Resolves the harmonic monotony in bars 24-28.",
222 state="open",
223 from_branch="feat/tritone-subs",
224 to_branch="main",
225 )
226 db_session.add(proposal)
227 await db_session.commit()
228
229 response = await client.get(
230 f"/api/repos/{repo_id}/context",
231 headers=auth_headers,
232 )
233 assert response.status_code == 200
234 proposals_ctx = response.json()["activeProposals"]
235
236 assert isinstance(proposals_ctx, list)
237 assert len(proposals_ctx) == 1
238 assert proposals_ctx[0]["title"] == "Add tritone substitution in bridge"
239 assert proposals_ctx[0]["state"] == "open"
240 assert "proposalId" in proposals_ctx[0]
241 assert "fromBranch" in proposals_ctx[0]
242 assert "toBranch" in proposals_ctx[0]
243
244
245 # ---------------------------------------------------------------------------
246 # test_context_brief_depth
247 # ---------------------------------------------------------------------------
248
249
250 async def test_context_brief_depth(
251 client: AsyncClient,
252 auth_headers: StrDict,
253 db_session: AsyncSession,
254 ) -> None:
255 """?depth=brief returns minimal context — at most 3 history entries and 2 suggestions."""
256 repo_id = await _create_repo(client, auth_headers)
257 await _seed_repo_with_commits(db_session, repo_id, num_commits=8)
258 await db_session.commit()
259
260 response = await client.get(
261 f"/api/repos/{repo_id}/context?depth=brief",
262 headers=auth_headers,
263 )
264 assert response.status_code == 200
265 body = response.json()
266
267 assert body["depth"] == "brief"
268 assert len(body["history"]) <= 3
269 assert len(body["suggestions"]) <= 2
270
271
272 # ---------------------------------------------------------------------------
273 # test_context_standard_depth
274 # ---------------------------------------------------------------------------
275
276
277 async def test_context_standard_depth(
278 client: AsyncClient,
279 auth_headers: StrDict,
280 db_session: AsyncSession,
281 ) -> None:
282 """?depth=standard (default) returns at most 10 history entries."""
283 repo_id = await _create_repo(client, auth_headers)
284 await _seed_repo_with_commits(db_session, repo_id, num_commits=15)
285 await db_session.commit()
286
287 response = await client.get(
288 f"/api/repos/{repo_id}/context?depth=standard",
289 headers=auth_headers,
290 )
291 assert response.status_code == 200
292 body = response.json()
293
294 assert body["depth"] == "standard"
295 assert len(body["history"]) <= 10
296
297
298 # ---------------------------------------------------------------------------
299 # test_context_verbose_depth_includes_issue_bodies
300 # ---------------------------------------------------------------------------
301
302
303 async def test_context_verbose_depth_includes_issue_bodies(
304 client: AsyncClient,
305 auth_headers: StrDict,
306 db_session: AsyncSession,
307 ) -> None:
308 """?depth=verbose includes full issue bodies; brief/standard do not."""
309 repo_id = await _create_repo(client, auth_headers)
310 await _seed_repo_with_commits(db_session, repo_id)
311
312 import uuid
313
314 issue = MusehubIssue(
315 repo_id=repo_id,
316 number=1,
317 title="Add more harmonic tension",
318 body="Consider a tritone substitution in bar 24 to create tension before the resolution.",
319 state="open",
320 labels=["harmonic", "composition"],
321 )
322 db_session.add(issue)
323 await db_session.commit()
324
325 # brief: body should be empty string
326 brief_resp = await client.get(
327 f"/api/repos/{repo_id}/context?depth=brief",
328 headers=auth_headers,
329 )
330 assert brief_resp.status_code == 200
331 brief_issues = brief_resp.json()["openIssues"]
332 assert len(brief_issues) == 1
333 assert brief_issues[0]["body"] == ""
334
335 # verbose: body should be included
336 verbose_resp = await client.get(
337 f"/api/repos/{repo_id}/context?depth=verbose",
338 headers=auth_headers,
339 )
340 assert verbose_resp.status_code == 200
341 verbose_issues = verbose_resp.json()["openIssues"]
342 assert len(verbose_issues) == 1
343 assert "tritone substitution" in verbose_issues[0]["body"]
344
345
346 # ---------------------------------------------------------------------------
347 # test_context_yaml_format
348 # ---------------------------------------------------------------------------
349
350
351 async def test_context_yaml_format(
352 client: AsyncClient,
353 auth_headers: StrDict,
354 db_session: AsyncSession,
355 ) -> None:
356 """?format=yaml returns valid YAML with the same structure as JSON."""
357 repo_id = await _create_repo(client, auth_headers)
358 await _seed_repo_with_commits(db_session, repo_id)
359 await db_session.commit()
360
361 response = await client.get(
362 f"/api/repos/{repo_id}/context?format=yaml",
363 headers=auth_headers,
364 )
365 assert response.status_code == 200
366 assert "yaml" in response.headers["content-type"]
367
368 parsed = yaml.safe_load(response.text)
369 assert isinstance(parsed, dict)
370 assert "repoId" in parsed
371 assert "musicalState" in parsed
372 assert "history" in parsed
373 assert "analysis" in parsed
374
375
376 # ---------------------------------------------------------------------------
377 # test_context_unknown_repo_404
378 # ---------------------------------------------------------------------------
379
380
381 async def test_context_unknown_repo_404(
382 client: AsyncClient,
383 auth_headers: StrDict,
384 ) -> None:
385 """GET /repos/{unknown_id}/context returns 404 for a non-existent repo."""
386 response = await client.get(
387 "/api/repos/nonexistent-repo-id/context",
388 headers=auth_headers,
389 )
390 assert response.status_code == 404
391
392
393 # ---------------------------------------------------------------------------
394 # test_context_ref_not_found_404
395 # ---------------------------------------------------------------------------
396
397
398 async def test_context_ref_not_found_404(
399 client: AsyncClient,
400 auth_headers: StrDict,
401 db_session: AsyncSession,
402 ) -> None:
403 """GET .../context?ref=nonexistent returns 404 when the ref has no commits."""
404 repo_id = await _create_repo(client, auth_headers)
405 await db_session.commit()
406
407 response = await client.get(
408 f"/api/repos/{repo_id}/context?ref=nonexistent-branch",
409 headers=auth_headers,
410 )
411 assert response.status_code == 404
412
413
414 # ---------------------------------------------------------------------------
415 # test_context_requires_auth
416 # ---------------------------------------------------------------------------
417
418
419 async def test_context_nonexistent_repo_returns_404_without_auth(
420 client: AsyncClient,
421 db_session: AsyncSession,
422 ) -> None:
423 """GET /repos/{repo_id}/context returns 404 for a non-existent repo without auth.
424
425 Context endpoint uses optional_token — auth check is visibility-based,
426 so a missing repo returns 404 before the auth check fires.
427 """
428 response = await client.get(
429 "/api/repos/non-existent-repo-id/context",
430 )
431 assert response.status_code == 404
432
433
434 # ---------------------------------------------------------------------------
435 # test_context_default_ref_resolves_to_latest_commit
436 # ---------------------------------------------------------------------------
437
438
439 async def test_context_default_ref_resolves_to_latest_commit(
440 client: AsyncClient,
441 auth_headers: StrDict,
442 db_session: AsyncSession,
443 ) -> None:
444 """?ref=HEAD (default) resolves to the latest commit and returns a valid ref in response."""
445 repo_id = await _create_repo(client, auth_headers)
446 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
447 await db_session.commit()
448
449 response = await client.get(
450 f"/api/repos/{repo_id}/context",
451 headers=auth_headers,
452 )
453 assert response.status_code == 200
454 body = response.json()
455
456 # ref should resolve to a branch name or commit id (not literally "HEAD")
457 assert body["ref"] != ""
458
459
460 # ---------------------------------------------------------------------------
461 # test_context_branch_ref_resolution
462 # ---------------------------------------------------------------------------
463
464
465 async def test_context_branch_ref_resolution(
466 client: AsyncClient,
467 auth_headers: StrDict,
468 db_session: AsyncSession,
469 ) -> None:
470 """?ref=<branch_name> resolves the branch head commit."""
471 repo_id = await _create_repo(client, auth_headers)
472 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
473 await db_session.commit()
474
475 response = await client.get(
476 f"/api/repos/{repo_id}/context?ref=main",
477 headers=auth_headers,
478 )
479 assert response.status_code == 200
480 body = response.json()
481 assert body["ref"] == "main"
482
483
484 # ---------------------------------------------------------------------------
485 # test_context_suggestions_generated
486 # ---------------------------------------------------------------------------
487
488
489 async def test_context_suggestions_generated(
490 client: AsyncClient,
491 auth_headers: StrDict,
492 db_session: AsyncSession,
493 ) -> None:
494 """Suggestions are generated and returned as a list of strings."""
495 repo_id = await _create_repo(client, auth_headers)
496 await _seed_repo_with_commits(db_session, repo_id)
497 await db_session.commit()
498
499 response = await client.get(
500 f"/api/repos/{repo_id}/context",
501 headers=auth_headers,
502 )
503 assert response.status_code == 200
504 suggestions = response.json()["suggestions"]
505
506 assert isinstance(suggestions, list)
507 assert all(isinstance(s, str) for s in suggestions)
508 # At least one suggestion since no key/tempo detected (stubs)
509 assert len(suggestions) >= 1
510
511
512 # ---------------------------------------------------------------------------
513 # test_context_open_issues_excluded_when_closed
514 # ---------------------------------------------------------------------------
515
516
517 async def test_context_open_issues_excluded_when_closed(
518 client: AsyncClient,
519 auth_headers: StrDict,
520 db_session: AsyncSession,
521 ) -> None:
522 """Closed issues do not appear in the open_issues section."""
523 repo_id = await _create_repo(client, auth_headers)
524 await _seed_repo_with_commits(db_session, repo_id)
525
526 closed_issue = MusehubIssue(
527 repo_id=repo_id,
528 number=1,
529 title="Closed: fix the bridge",
530 body="Already fixed.",
531 state="closed",
532 labels=[],
533 )
534 open_issue = MusehubIssue(
535 repo_id=repo_id,
536 number=2,
537 title="Add swing feel to verse",
538 body="",
539 state="open",
540 labels=["groove"],
541 )
542 db_session.add(closed_issue)
543 db_session.add(open_issue)
544 await db_session.commit()
545
546 response = await client.get(
547 f"/api/repos/{repo_id}/context",
548 headers=auth_headers,
549 )
550 assert response.status_code == 200
551 issues = response.json()["openIssues"]
552
553 assert len(issues) == 1
554 assert issues[0]["title"] == "Add swing feel to verse"
555 assert issues[0]["number"] == 2
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago