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