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