gabriel / musehub public
test_context.py python
1,018 lines 36.0 KB
Raw
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32 fix: fall back to DB ancestry check when mpack-only fast-fo… Sonnet 4.6 patch 6 days ago
1 """Section 23 — Agent Context API: 7-layer test suite.
2
3 Covers musehub/services/musehub_context.py and musehub/models/musehub_context.py.
4 The 15 existing tests in test_musehub_context.py cover E2E + integration basics;
5 this suite adds unit, stress, data-integrity, security, and performance layers.
6
7 Layer map
8 ---------
9 1. Unit — pure functions, constants, Pydantic models
10 2. Integration — service functions against real PostgreSQL DB
11 3. E2E — HTTP client against the full app
12 4. Stress — large datasets, concurrent requests
13 5. Data Integrity — ordering, filtering, exclusion rules
14 6. Security — auth enforcement, private repo visibility
15 7. Performance — timing budgets
16 """
17 from __future__ import annotations
18
19 import asyncio
20 import secrets
21 import time
22 from datetime import datetime, timezone
23
24 import pytest
25 from httpx import AsyncClient
26 from muse.core.types import fake_id
27 from musehub.core.genesis import compute_identity_id, compute_repo_id
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from musehub.types.json_types import StrDict
31 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
32 from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal
33 from musehub.models.musehub_context import (
34 ActiveProposalContext,
35 AgentContextResponse,
36 AnalysisSummaryContext,
37 ContextDepth,
38 ContextFormat,
39 HistoryEntryContext,
40 MusicalStateContext,
41 OpenIssueContext,
42 )
43 from musehub.services.musehub_context import (
44 _HISTORY_LIMIT,
45 _INCLUDE_ISSUE_BODY,
46 _INCLUDE_PROPOSAL_BODY,
47 _extract_tracks_from_snapshot,
48 _generate_suggestions,
49 _get_latest_commit,
50 _get_open_issues,
51 _get_open_proposals,
52 _resolve_ref_to_commit,
53 _utc_iso,
54 build_agent_context,
55 )
56
57
58 # ---------------------------------------------------------------------------
59 # DB helpers
60 # ---------------------------------------------------------------------------
61
62
63 def _uid() -> str:
64 return secrets.token_hex(16)
65
66
67 async def _db_repo(session: AsyncSession, *, visibility: str = "private") -> str:
68 slug = f"test-repo-{_uid()[:8]}"
69 owner_id = compute_identity_id(b"testuser")
70 created_at = datetime.now(tz=timezone.utc)
71 repo = MusehubRepo(
72 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
73 name=slug,
74 slug=slug,
75 owner="testuser",
76 owner_user_id=owner_id,
77 visibility=visibility,
78 created_at=created_at,
79 updated_at=created_at,
80 )
81 session.add(repo)
82 await session.flush()
83 return repo.repo_id
84
85
86 async def _db_commit(
87 session: AsyncSession,
88 repo_id: str,
89 *,
90 branch: str = "main",
91 message: str = "add groove",
92 ts: datetime | None = None,
93 parent_id: str | None = None,
94 ) -> str:
95 commit_id = _uid().replace("-", "")
96 c = MusehubCommit(
97 commit_id=commit_id,
98 branch=branch,
99 parent_ids=[parent_id] if parent_id else [],
100 message=message,
101 author="agent",
102 timestamp=ts or datetime.now(timezone.utc),
103 )
104 session.add(c)
105 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
106 await session.flush()
107 return commit_id
108
109
110 async def _db_branch(session: AsyncSession, repo_id: str, name: str, head: str) -> None:
111 session.add(MusehubBranch(branch_id=fake_id(f"{repo_id}-branch-{name}"), repo_id=repo_id, name=name, head_commit_id=head))
112 await session.flush()
113
114
115 async def _db_issue(
116 session: AsyncSession,
117 repo_id: str,
118 *,
119 number: int = 1,
120 title: str = "fix harmony",
121 body: str = "needs fixing",
122 state: str = "open",
123 labels: list[str] | None = None,
124 ) -> str:
125 issue = MusehubIssue(
126 issue_id=fake_id(f"{repo_id}-issue-{number}"),
127 repo_id=repo_id,
128 number=number,
129 title=title,
130 body=body,
131 state=state,
132 labels=labels or [],
133 )
134 session.add(issue)
135 await session.flush()
136 return issue.issue_id
137
138
139 async def _db_proposal_ctx(
140 session: AsyncSession,
141 repo_id: str,
142 *,
143 proposal_number: int = 1,
144 title: str = "add tritone sub",
145 body: str = "see description",
146 state: str = "open",
147 from_branch: str = "feat/x",
148 to_branch: str = "main",
149 ) -> str:
150 proposal = MusehubProposal(
151 proposal_id=fake_id(f"{repo_id}-proposal-{proposal_number}"),
152 repo_id=repo_id,
153 proposal_number=proposal_number,
154 title=title,
155 body=body,
156 state=state,
157 from_branch=from_branch,
158 to_branch=to_branch,
159 )
160 session.add(proposal)
161 await session.flush()
162 return proposal.proposal_id
163
164
165 async def _api_repo(
166 client: AsyncClient,
167 auth_headers: StrDict,
168 *,
169 name: str | None = None,
170 visibility: str = "private",
171 ) -> str:
172 name = name or f"repo-{_uid()[:8]}"
173 r = await client.post(
174 "/api/repos",
175 json={"name": name, "owner": "testuser", "visibility": visibility},
176 headers=auth_headers,
177 )
178 assert r.status_code == 201, r.text
179 return r.json()["repoId"]
180
181
182 # ===========================================================================
183 # Layer 1 — Unit
184 # ===========================================================================
185
186
187 class TestUnitUtcIso:
188 def test_naive_datetime_gets_utc(self) -> None:
189 dt = datetime(2026, 1, 15, 12, 0, 0)
190 result = _utc_iso(dt)
191 assert "+00:00" in result or "Z" in result.upper() or "UTC" in result
192 assert "2026-01-15" in result
193
194 def test_aware_datetime_preserved(self) -> None:
195 dt = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
196 result = _utc_iso(dt)
197 assert "2026-06-01" in result
198
199 def test_returns_string(self) -> None:
200 assert isinstance(_utc_iso(datetime.now(timezone.utc)), str)
201
202 def test_iso_format_parseable(self) -> None:
203 dt = datetime(2025, 3, 14, 9, 26, 53, tzinfo=timezone.utc)
204 result = _utc_iso(dt)
205 parsed = datetime.fromisoformat(result)
206 assert parsed.year == 2025
207 assert parsed.month == 3
208 assert parsed.day == 14
209
210
211 class TestUnitExtractTracks:
212 def test_none_snapshot_returns_empty(self) -> None:
213 assert _extract_tracks_from_snapshot(None) == []
214
215 def test_any_object_returns_empty(self) -> None:
216 # stub returns [] regardless — just verifies the contract
217 assert _extract_tracks_from_snapshot(object()) == []
218
219 def test_returns_list(self) -> None:
220 result = _extract_tracks_from_snapshot(None)
221 assert isinstance(result, list)
222
223
224 class TestUnitHistoryLimit:
225 def test_brief_is_three(self) -> None:
226 assert _HISTORY_LIMIT[ContextDepth.brief] == 3
227
228 def test_standard_is_ten(self) -> None:
229 assert _HISTORY_LIMIT[ContextDepth.standard] == 10
230
231 def test_verbose_is_fifty(self) -> None:
232 assert _HISTORY_LIMIT[ContextDepth.verbose] == 50
233
234 def test_all_depths_covered(self) -> None:
235 for depth in ContextDepth:
236 assert depth in _HISTORY_LIMIT
237
238
239 class TestUnitIncludeFlags:
240 def test_proposal_body_brief_false(self) -> None:
241 assert _INCLUDE_PROPOSAL_BODY[ContextDepth.brief] is False
242
243 def test_proposal_body_standard_true(self) -> None:
244 assert _INCLUDE_PROPOSAL_BODY[ContextDepth.standard] is True
245
246 def test_proposal_body_verbose_true(self) -> None:
247 assert _INCLUDE_PROPOSAL_BODY[ContextDepth.verbose] is True
248
249 def test_issue_body_brief_false(self) -> None:
250 assert _INCLUDE_ISSUE_BODY[ContextDepth.brief] is False
251
252 def test_issue_body_standard_false(self) -> None:
253 assert _INCLUDE_ISSUE_BODY[ContextDepth.standard] is False
254
255 def test_issue_body_verbose_true(self) -> None:
256 assert _INCLUDE_ISSUE_BODY[ContextDepth.verbose] is True
257
258
259 class TestUnitGenerateSuggestions:
260 def _empty_state(self) -> MusicalStateContext:
261 return MusicalStateContext(active_tracks=[])
262
263 def _state_with_tracks(self) -> MusicalStateContext:
264 return MusicalStateContext(active_tracks=["drums", "bass"])
265
266 def _issue(self, n: int = 1) -> OpenIssueContext:
267 return OpenIssueContext(
268 issue_id=_uid(), number=n, title=f"issue {n}", labels=[], body=""
269 )
270
271 def _proposal_ctx(self) -> ActiveProposalContext:
272 return ActiveProposalContext(
273 proposal_id=_uid(),
274 title="add swing feel",
275 from_branch="feat/swing",
276 to_branch="main",
277 state="open",
278 body="",
279 )
280
281 def test_no_tracks_generates_suggestion(self) -> None:
282 s = _generate_suggestions(self._empty_state(), [], [], ContextDepth.standard)
283 assert len(s) >= 1
284 assert any("No files" in x for x in s)
285
286 def test_with_tracks_no_no_files_suggestion(self) -> None:
287 s = _generate_suggestions(
288 self._state_with_tracks(), [], [], ContextDepth.standard
289 )
290 assert not any("No files" in x for x in s)
291
292 def test_open_issue_generates_suggestion(self) -> None:
293 s = _generate_suggestions(
294 self._state_with_tracks(), [self._issue(5)], [], ContextDepth.standard
295 )
296 assert any("#5" in x for x in s)
297
298 def test_open_pr_generates_suggestion(self) -> None:
299 s = _generate_suggestions(
300 self._state_with_tracks(), [], [self._proposal_ctx()], ContextDepth.standard
301 )
302 assert any("add swing feel" in x for x in s)
303
304 def test_brief_caps_at_two(self) -> None:
305 # force 3 suggestions: no tracks + issue + proposal
306 s = _generate_suggestions(
307 self._empty_state(), [self._issue()], [self._proposal_ctx()], ContextDepth.brief
308 )
309 assert len(s) <= 2
310
311 def test_standard_caps_at_four(self) -> None:
312 issues = [self._issue(i) for i in range(1, 5)]
313 proposals_ctx = [self._proposal_ctx()]
314 # empty state + 4 issues + 1 proposal = 6 raw suggestions; capped at 4
315 s = _generate_suggestions(self._empty_state(), issues, proposals_ctx, ContextDepth.standard)
316 assert len(s) <= 4
317
318 def test_verbose_uncapped(self) -> None:
319 issues = [self._issue(i) for i in range(1, 5)]
320 proposals_ctx = [self._proposal_ctx()]
321 s = _generate_suggestions(self._empty_state(), issues, proposals_ctx, ContextDepth.verbose)
322 # 1 (no tracks) + 1 (first issue) + 1 (first proposal) = 3 — all returned
323 assert len(s) == 3
324
325 def test_returns_strings(self) -> None:
326 s = _generate_suggestions(self._empty_state(), [], [], ContextDepth.brief)
327 assert all(isinstance(x, str) for x in s)
328
329 def test_deterministic(self) -> None:
330 state = self._empty_state()
331 issues = [self._issue()]
332 proposals_ctx = [self._proposal_ctx()]
333 s1 = _generate_suggestions(state, issues, proposals_ctx, ContextDepth.standard)
334 s2 = _generate_suggestions(state, issues, proposals_ctx, ContextDepth.standard)
335 assert s1 == s2
336
337
338 class TestUnitModels:
339 def test_context_depth_values(self) -> None:
340 assert ContextDepth.brief == "brief"
341 assert ContextDepth.standard == "standard"
342 assert ContextDepth.verbose == "verbose"
343
344 def test_context_format_values(self) -> None:
345 assert ContextFormat.json == "json"
346 assert ContextFormat.yaml == "yaml"
347
348 def test_musical_state_default_empty_tracks(self) -> None:
349 m = MusicalStateContext()
350 assert m.active_tracks == []
351
352 def test_history_entry_context_fields(self) -> None:
353 h = HistoryEntryContext(
354 commit_id="abc123",
355 message="add bass",
356 author="agent",
357 timestamp="2026-01-01T00:00:00+00:00",
358 )
359 assert h.commit_id == "abc123"
360 assert h.active_tracks == []
361
362 def test_analysis_all_none_by_default(self) -> None:
363 a = AnalysisSummaryContext()
364 assert a.key_finding is None
365 assert a.chord_progression is None
366 assert a.groove_score is None
367 assert a.emotion is None
368 assert a.harmonic_tension is None
369 assert a.melodic_contour is None
370
371 def test_open_issue_context_defaults(self) -> None:
372 i = OpenIssueContext(issue_id=_uid(), number=1, title="fix")
373 assert i.labels == []
374 assert i.body == ""
375
376 def test_active_pr_context_defaults(self) -> None:
377 p = ActiveProposalContext(
378 proposal_id=_uid(),
379 title="Proposal",
380 from_branch="a",
381 to_branch="b",
382 state="open",
383 )
384 assert p.body == ""
385
386 def test_agent_context_response_camel_fields(self) -> None:
387 resp = AgentContextResponse(
388 repo_id="r1",
389 ref="main",
390 depth="standard",
391 musical_state=MusicalStateContext(),
392 analysis=AnalysisSummaryContext(),
393 )
394 d = resp.model_dump(by_alias=True)
395 assert "repoId" in d
396 assert "musicalState" in d
397 assert "activeProposals" in d
398 assert "openIssues" in d
399
400
401 # ===========================================================================
402 # Layer 2 — Integration
403 # ===========================================================================
404
405
406 class TestIntegrationResolveRef:
407 async def test_resolve_branch_name(self, db_session: AsyncSession) -> None:
408 repo_id = await _db_repo(db_session)
409 commit_id = await _db_commit(db_session, repo_id)
410 await _db_branch(db_session, repo_id, "main", commit_id)
411 await db_session.flush()
412
413 result = await _resolve_ref_to_commit(db_session, repo_id, "main")
414 assert result is not None
415 assert result.commit_id == commit_id
416
417 async def test_resolve_commit_id_directly(self, db_session: AsyncSession) -> None:
418 repo_id = await _db_repo(db_session)
419 commit_id = await _db_commit(db_session, repo_id)
420 await db_session.flush()
421
422 result = await _resolve_ref_to_commit(db_session, repo_id, commit_id)
423 assert result is not None
424 assert result.commit_id == commit_id
425
426 async def test_resolve_nonexistent_returns_none(self, db_session: AsyncSession) -> None:
427 repo_id = await _db_repo(db_session)
428 await db_session.flush()
429
430 result = await _resolve_ref_to_commit(db_session, repo_id, "nonexistent-ref")
431 assert result is None
432
433 async def test_branch_takes_priority_over_commit_id(self, db_session: AsyncSession) -> None:
434 """If a branch name happens to equal a commit ID substring, branch wins."""
435 repo_id = await _db_repo(db_session)
436 commit_id = await _db_commit(db_session, repo_id)
437 branch_commit_id = await _db_commit(db_session, repo_id, message="branch head")
438 await _db_branch(db_session, repo_id, "main", branch_commit_id)
439 await db_session.flush()
440
441 # Resolving "main" returns the branch head, not commit_id
442 result = await _resolve_ref_to_commit(db_session, repo_id, "main")
443 assert result is not None
444 assert result.commit_id == branch_commit_id
445
446
447 class TestIntegrationGetLatestCommit:
448 async def test_returns_most_recent(self, db_session: AsyncSession) -> None:
449 repo_id = await _db_repo(db_session)
450 ts_old = datetime(2026, 1, 1, tzinfo=timezone.utc)
451 ts_new = datetime(2026, 6, 1, tzinfo=timezone.utc)
452 await _db_commit(db_session, repo_id, ts=ts_old, message="old")
453 new_id = await _db_commit(db_session, repo_id, ts=ts_new, message="new")
454 await db_session.flush()
455
456 result = await _get_latest_commit(db_session, repo_id)
457 assert result is not None
458 assert result.commit_id == new_id
459
460 async def test_no_commits_returns_none(self, db_session: AsyncSession) -> None:
461 repo_id = await _db_repo(db_session)
462 await db_session.flush()
463
464 result = await _get_latest_commit(db_session, repo_id)
465 assert result is None
466
467
468 class TestIntegrationGetOpenProposals:
469 async def test_include_body_true(self, db_session: AsyncSession) -> None:
470 repo_id = await _db_repo(db_session)
471 await _db_proposal_ctx(db_session, repo_id, body="detailed body text")
472 await db_session.flush()
473
474 results = await _get_open_proposals(db_session, repo_id, include_body=True)
475 assert len(results) == 1
476 assert results[0].body == "detailed body text"
477
478 async def test_include_body_false(self, db_session: AsyncSession) -> None:
479 repo_id = await _db_repo(db_session)
480 await _db_proposal_ctx(db_session, repo_id, body="detailed body text")
481 await db_session.flush()
482
483 results = await _get_open_proposals(db_session, repo_id, include_body=False)
484 assert len(results) == 1
485 assert results[0].body == ""
486
487 async def test_closed_prs_excluded(self, db_session: AsyncSession) -> None:
488 repo_id = await _db_repo(db_session)
489 await _db_proposal_ctx(db_session, repo_id, state="closed")
490 await db_session.flush()
491
492 results = await _get_open_proposals(db_session, repo_id, include_body=False)
493 assert results == []
494
495
496 class TestIntegrationGetOpenIssues:
497 async def test_include_body_verbose(self, db_session: AsyncSession) -> None:
498 repo_id = await _db_repo(db_session)
499 await _db_issue(db_session, repo_id, body="full body text")
500 await db_session.flush()
501
502 results = await _get_open_issues(db_session, repo_id, include_body=True)
503 assert len(results) == 1
504 assert results[0].body == "full body text"
505
506 async def test_include_body_false_empty_string(self, db_session: AsyncSession) -> None:
507 repo_id = await _db_repo(db_session)
508 await _db_issue(db_session, repo_id, body="full body text")
509 await db_session.flush()
510
511 results = await _get_open_issues(db_session, repo_id, include_body=False)
512 assert results[0].body == ""
513
514 async def test_closed_issues_excluded(self, db_session: AsyncSession) -> None:
515 repo_id = await _db_repo(db_session)
516 await _db_issue(db_session, repo_id, state="closed")
517 await db_session.flush()
518
519 results = await _get_open_issues(db_session, repo_id, include_body=False)
520 assert results == []
521
522 async def test_ordered_by_number(self, db_session: AsyncSession) -> None:
523 repo_id = await _db_repo(db_session)
524 await _db_issue(db_session, repo_id, number=5, title="five")
525 await _db_issue(db_session, repo_id, number=2, title="two")
526 await _db_issue(db_session, repo_id, number=8, title="eight")
527 await db_session.flush()
528
529 results = await _get_open_issues(db_session, repo_id, include_body=False)
530 assert [r.number for r in results] == [2, 5, 8]
531
532
533 class TestIntegrationBuildAgentContext:
534 async def test_repo_not_found_returns_none(self, db_session: AsyncSession) -> None:
535 result = await build_agent_context(
536 db_session, repo_id="nonexistent-repo", ref="main"
537 )
538 assert result is None
539
540 async def test_no_commits_returns_none(self, db_session: AsyncSession) -> None:
541 repo_id = await _db_repo(db_session)
542 await db_session.flush()
543
544 result = await build_agent_context(db_session, repo_id=repo_id, ref="HEAD")
545 assert result is None
546
547 async def test_head_resolves_to_latest(self, db_session: AsyncSession) -> None:
548 repo_id = await _db_repo(db_session)
549 ts_old = datetime(2026, 1, 1, tzinfo=timezone.utc)
550 ts_new = datetime(2026, 6, 1, tzinfo=timezone.utc)
551 await _db_commit(db_session, repo_id, ts=ts_old, branch="main", message="old")
552 new_id = await _db_commit(
553 db_session, repo_id, ts=ts_new, branch="main", message="new"
554 )
555 await _db_branch(db_session, repo_id, "main", new_id)
556 await db_session.flush()
557
558 result = await build_agent_context(db_session, repo_id=repo_id, ref="HEAD")
559 assert result is not None
560 assert result.repo_id == repo_id
561 # History excludes the head commit; head is the new one
562 history_ids = [h.commit_id for h in result.history]
563 assert new_id not in history_ids
564
565 async def test_branch_ref_resolution(self, db_session: AsyncSession) -> None:
566 repo_id = await _db_repo(db_session)
567 commit_id = await _db_commit(db_session, repo_id, branch="feature")
568 await _db_branch(db_session, repo_id, "feature", commit_id)
569 await db_session.flush()
570
571 result = await build_agent_context(
572 db_session, repo_id=repo_id, ref="feature"
573 )
574 assert result is not None
575 assert result.ref == "feature"
576
577 async def test_brief_depth_history_limit(self, db_session: AsyncSession) -> None:
578 repo_id = await _db_repo(db_session)
579 for i in range(8):
580 ts = datetime(2026, 1, i + 1, tzinfo=timezone.utc)
581 await _db_commit(
582 db_session, repo_id, ts=ts, branch="main", message=f"commit {i}"
583 )
584 await db_session.flush()
585
586 result = await build_agent_context(
587 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.brief
588 )
589 assert result is not None
590 assert len(result.history) <= 3
591
592 async def test_verbose_depth_issue_body_included(
593 self, db_session: AsyncSession
594 ) -> None:
595 repo_id = await _db_repo(db_session)
596 await _db_commit(db_session, repo_id)
597 await _db_issue(db_session, repo_id, body="verbose body")
598 await db_session.flush()
599
600 result = await build_agent_context(
601 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
602 )
603 assert result is not None
604 assert len(result.open_issues) == 1
605 assert result.open_issues[0].body == "verbose body"
606
607 async def test_standard_depth_issue_body_empty(
608 self, db_session: AsyncSession
609 ) -> None:
610 repo_id = await _db_repo(db_session)
611 await _db_commit(db_session, repo_id)
612 await _db_issue(db_session, repo_id, body="hidden")
613 await db_session.flush()
614
615 result = await build_agent_context(
616 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.standard
617 )
618 assert result is not None
619 assert result.open_issues[0].body == ""
620
621
622 # ===========================================================================
623 # Layer 3 — E2E
624 # ===========================================================================
625
626
627 class TestE2EContextEndpoint:
628 async def test_200_with_all_sections(
629 self,
630 client: AsyncClient,
631 auth_headers: StrDict,
632 db_session: AsyncSession,
633 ) -> None:
634 repo_id = await _api_repo(client, auth_headers)
635 await _db_commit(db_session, repo_id)
636 await db_session.commit()
637
638 r = await client.get(f"/api/repos/{repo_id}/context", headers=auth_headers)
639 assert r.status_code == 200
640 body = r.json()
641 for key in ("repoId", "ref", "depth", "musicalState", "history", "analysis", "activeProposals", "openIssues", "suggestions"):
642 assert key in body
643
644 async def test_depth_brief_param(
645 self,
646 client: AsyncClient,
647 auth_headers: StrDict,
648 db_session: AsyncSession,
649 ) -> None:
650 repo_id = await _api_repo(client, auth_headers)
651 for i in range(6):
652 await _db_commit(db_session, repo_id, message=f"c{i}")
653 await db_session.commit()
654
655 r = await client.get(
656 f"/api/repos/{repo_id}/context?depth=brief", headers=auth_headers
657 )
658 assert r.status_code == 200
659 body = r.json()
660 assert body["depth"] == "brief"
661 assert len(body["history"]) <= 3
662
663 async def test_depth_verbose_param(
664 self,
665 client: AsyncClient,
666 auth_headers: StrDict,
667 db_session: AsyncSession,
668 ) -> None:
669 repo_id = await _api_repo(client, auth_headers)
670 await _db_commit(db_session, repo_id)
671 await _db_issue(db_session, repo_id, body="full body verbose")
672 await db_session.commit()
673
674 r = await client.get(
675 f"/api/repos/{repo_id}/context?depth=verbose", headers=auth_headers
676 )
677 assert r.status_code == 200
678 body = r.json()
679 assert body["depth"] == "verbose"
680 assert body["openIssues"][0]["body"] == "full body verbose"
681
682 async def test_invalid_depth_422(
683 self,
684 client: AsyncClient,
685 auth_headers: StrDict,
686 ) -> None:
687 r = await client.get(
688 "/api/repos/any-id/context?depth=ultra", headers=auth_headers
689 )
690 assert r.status_code == 422
691
692 async def test_unknown_repo_404(
693 self,
694 client: AsyncClient,
695 auth_headers: StrDict,
696 ) -> None:
697 r = await client.get(
698 "/api/repos/no-such-repo/context", headers=auth_headers
699 )
700 assert r.status_code == 404
701
702 async def test_nonexistent_ref_404(
703 self,
704 client: AsyncClient,
705 auth_headers: StrDict,
706 db_session: AsyncSession,
707 ) -> None:
708 repo_id = await _api_repo(client, auth_headers)
709 await db_session.commit()
710
711 r = await client.get(
712 f"/api/repos/{repo_id}/context?ref=no-such-branch", headers=auth_headers
713 )
714 assert r.status_code == 404
715
716 async def test_yaml_format_returns_yaml_content_type(
717 self,
718 client: AsyncClient,
719 auth_headers: StrDict,
720 db_session: AsyncSession,
721 ) -> None:
722 import yaml
723
724 repo_id = await _api_repo(client, auth_headers)
725 await _db_commit(db_session, repo_id)
726 await db_session.commit()
727
728 r = await client.get(
729 f"/api/repos/{repo_id}/context?format=yaml", headers=auth_headers
730 )
731 assert r.status_code == 200
732 assert "yaml" in r.headers.get("content-type", "")
733 parsed = yaml.safe_load(r.text)
734 assert isinstance(parsed, dict)
735 assert "repoId" in parsed
736
737
738 # ===========================================================================
739 # Layer 4 — Stress
740 # ===========================================================================
741
742
743 class TestStress:
744 async def test_verbose_depth_50_commit_history(
745 self,
746 db_session: AsyncSession,
747 ) -> None:
748 """build_agent_context handles 60 commits; verbose history capped at 50."""
749 repo_id = await _db_repo(db_session)
750 for i in range(60):
751 ts = datetime(2026, 1, 1, 0, i, 0, tzinfo=timezone.utc)
752 await _db_commit(db_session, repo_id, ts=ts, message=f"commit {i}")
753 await db_session.flush()
754
755 result = await build_agent_context(
756 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
757 )
758 assert result is not None
759 assert len(result.history) <= 50
760
761 async def test_concurrent_context_builds(
762 self,
763 db_session: AsyncSession,
764 ) -> None:
765 """5 concurrent build_agent_context calls on the same repo all succeed."""
766 repo_id = await _db_repo(db_session)
767 for i in range(5):
768 await _db_commit(db_session, repo_id, message=f"c{i}")
769 await db_session.flush()
770
771 results = await asyncio.gather(
772 *[
773 build_agent_context(
774 db_session, repo_id=repo_id, ref="HEAD"
775 )
776 for _ in range(5)
777 ]
778 )
779 assert all(r is not None for r in results)
780
781 async def test_many_open_issues_all_returned_verbose(
782 self,
783 db_session: AsyncSession,
784 ) -> None:
785 repo_id = await _db_repo(db_session)
786 await _db_commit(db_session, repo_id)
787 for i in range(20):
788 await _db_issue(db_session, repo_id, number=i + 1, title=f"issue {i}")
789 await db_session.flush()
790
791 result = await build_agent_context(
792 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
793 )
794 assert result is not None
795 assert len(result.open_issues) == 20
796
797
798 # ===========================================================================
799 # Layer 5 — Data Integrity
800 # ===========================================================================
801
802
803 class TestDataIntegrity:
804 async def test_history_newest_first(self, db_session: AsyncSession) -> None:
805 repo_id = await _db_repo(db_session)
806 for i in range(5):
807 ts = datetime(2026, 1, i + 1, tzinfo=timezone.utc)
808 await _db_commit(db_session, repo_id, ts=ts, message=f"c{i}")
809 await db_session.flush()
810
811 result = await build_agent_context(
812 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
813 )
814 assert result is not None
815 timestamps = [h.timestamp for h in result.history]
816 assert timestamps == sorted(timestamps, reverse=True)
817
818 async def test_head_commit_excluded_from_history(
819 self, db_session: AsyncSession
820 ) -> None:
821 repo_id = await _db_repo(db_session)
822 ts_old = datetime(2026, 1, 1, tzinfo=timezone.utc)
823 ts_new = datetime(2026, 6, 1, tzinfo=timezone.utc)
824 await _db_commit(db_session, repo_id, ts=ts_old, message="old")
825 new_id = await _db_commit(db_session, repo_id, ts=ts_new, message="new")
826 await db_session.flush()
827
828 # ref=HEAD resolves to new_id; it must NOT appear in history
829 result = await build_agent_context(
830 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
831 )
832 assert result is not None
833 history_ids = [h.commit_id for h in result.history]
834 assert new_id not in history_ids
835
836 async def test_closed_proposals_not_in_active_proposals(
837 self, db_session: AsyncSession
838 ) -> None:
839 repo_id = await _db_repo(db_session)
840 await _db_commit(db_session, repo_id)
841 await _db_proposal_ctx(db_session, repo_id, proposal_number=1, state="closed")
842 await _db_proposal_ctx(db_session, repo_id, proposal_number=2, state="merged", title="merged")
843 await db_session.flush()
844
845 result = await build_agent_context(
846 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
847 )
848 assert result is not None
849 assert result.active_proposals == []
850
851 async def test_closed_issues_not_in_open_issues(
852 self, db_session: AsyncSession
853 ) -> None:
854 repo_id = await _db_repo(db_session)
855 await _db_commit(db_session, repo_id)
856 await _db_issue(db_session, repo_id, state="closed")
857 await db_session.flush()
858
859 result = await build_agent_context(
860 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
861 )
862 assert result is not None
863 assert result.open_issues == []
864
865 async def test_proposal_body_empty_at_brief_depth(
866 self, db_session: AsyncSession
867 ) -> None:
868 repo_id = await _db_repo(db_session)
869 await _db_commit(db_session, repo_id)
870 await _db_proposal_ctx(db_session, repo_id, body="secret details")
871 await db_session.flush()
872
873 result = await build_agent_context(
874 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.brief
875 )
876 assert result is not None
877 assert result.active_proposals[0].body == ""
878
879 async def test_analysis_fields_all_none(self, db_session: AsyncSession) -> None:
880 repo_id = await _db_repo(db_session)
881 await _db_commit(db_session, repo_id)
882 await db_session.flush()
883
884 result = await build_agent_context(
885 db_session, repo_id=repo_id, ref="HEAD"
886 )
887 assert result is not None
888 a = result.analysis
889 assert a.key_finding is None
890 assert a.chord_progression is None
891 assert a.groove_score is None
892 assert a.emotion is None
893
894 async def test_repo_id_echoed_in_response(self, db_session: AsyncSession) -> None:
895 repo_id = await _db_repo(db_session)
896 await _db_commit(db_session, repo_id)
897 await db_session.flush()
898
899 result = await build_agent_context(db_session, repo_id=repo_id, ref="HEAD")
900 assert result is not None
901 assert result.repo_id == repo_id
902
903
904 # ===========================================================================
905 # Layer 6 — Security
906 # ===========================================================================
907
908
909 class TestSecurity:
910 async def test_private_repo_requires_auth(
911 self,
912 client: AsyncClient,
913 db_session: AsyncSession,
914 ) -> None:
915 """Context endpoint returns 403/401/404 for private repos without token."""
916 # Create repo and commit directly in DB (no auth_headers to avoid fixture override)
917 repo_id = await _db_repo(db_session, visibility="private")
918 await _db_commit(db_session, repo_id)
919 await db_session.commit()
920
921 r = await client.get(f"/api/repos/{repo_id}/context")
922 # private repo without auth → 403 or 401 (implementation may 404 for privacy)
923 assert r.status_code in (401, 403, 404)
924
925 async def test_public_repo_context_accessible_without_auth(
926 self,
927 client: AsyncClient,
928 db_session: AsyncSession,
929 ) -> None:
930 """Public repo context is readable without authentication."""
931 repo_id = await _db_repo(db_session, visibility="public")
932 await _db_commit(db_session, repo_id)
933 await db_session.commit()
934
935 r = await client.get(f"/api/repos/{repo_id}/context")
936 assert r.status_code == 200
937
938 async def test_sql_injection_in_ref_param_safe(
939 self,
940 client: AsyncClient,
941 auth_headers: StrDict,
942 db_session: AsyncSession,
943 ) -> None:
944 """SQL injection in ?ref param is handled safely (returns 404, not 500)."""
945 repo_id = await _api_repo(client, auth_headers)
946 await db_session.commit()
947
948 malicious_ref = "'; DROP TABLE musehub_commits; --"
949 r = await client.get(
950 f"/api/repos/{repo_id}/context",
951 params={"ref": malicious_ref},
952 headers=auth_headers,
953 )
954 assert r.status_code in (404, 422)
955
956 async def test_xss_in_ref_not_echoed_as_html(
957 self,
958 client: AsyncClient,
959 auth_headers: StrDict,
960 db_session: AsyncSession,
961 ) -> None:
962 """XSS attempt in ?ref is not reflected as raw HTML in a 200 response."""
963 repo_id = await _api_repo(client, auth_headers)
964 await db_session.commit()
965
966 r = await client.get(
967 f"/api/repos/{repo_id}/context",
968 params={"ref": "<script>alert(1)</script>"},
969 headers=auth_headers,
970 )
971 # Either rejected (404/422) or if echoed, must be JSON-escaped
972 if r.status_code == 200:
973 assert "<script>" not in r.text
974 else:
975 assert r.status_code in (404, 422)
976
977
978 # ===========================================================================
979 # Layer 7 — Performance
980 # ===========================================================================
981
982
983 class TestPerformance:
984 async def test_build_context_under_200ms(self, db_session: AsyncSession) -> None:
985 """build_agent_context for 20 commits completes in under 200ms."""
986 repo_id = await _db_repo(db_session)
987 for i in range(20):
988 ts = datetime(2026, 1, 1, 0, i, 0, tzinfo=timezone.utc)
989 await _db_commit(db_session, repo_id, ts=ts, message=f"commit {i}")
990 await db_session.flush()
991
992 start = time.perf_counter()
993 result = await build_agent_context(
994 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.standard
995 )
996 elapsed = time.perf_counter() - start
997
998 assert result is not None
999 assert elapsed < 0.2, f"build_agent_context took {elapsed:.3f}s, expected <0.2s"
1000
1001 async def test_verbose_context_50_commits_under_500ms(
1002 self, db_session: AsyncSession
1003 ) -> None:
1004 """Verbose depth with 55 commits (50 history + head) completes under 500ms."""
1005 repo_id = await _db_repo(db_session)
1006 for i in range(55):
1007 ts = datetime(2026, 1, 1, 0, 0, i, tzinfo=timezone.utc)
1008 await _db_commit(db_session, repo_id, ts=ts, message=f"commit {i}")
1009 await db_session.flush()
1010
1011 start = time.perf_counter()
1012 result = await build_agent_context(
1013 db_session, repo_id=repo_id, ref="HEAD", depth=ContextDepth.verbose
1014 )
1015 elapsed = time.perf_counter() - start
1016
1017 assert result is not None
1018 assert elapsed < 0.5, f"verbose build_agent_context took {elapsed:.3f}s"
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