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