gabriel / musehub public
test_musehub_ui_issue_detail_ssr.py python
662 lines 19.9 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for the SSR issue detail page — HTMX SSR + comment threading (issue #568).
2
3 Covers server-side rendering of issue body, comment thread, HTMX fragment
4 responses, status action buttons, sidebar, and 404 handling.
5
6 Test areas:
7 Basic rendering
8 - test_issue_detail_renders_title_server_side
9 - test_issue_detail_unknown_number_404
10
11 SSR body content
12 - test_issue_detail_renders_body_markdown
13 - test_issue_detail_empty_body_shows_placeholder
14
15 Comments
16 - test_issue_detail_renders_comments_server_side
17 - test_issue_detail_no_comments_shows_placeholder
18
19 HTMX attributes
20 - test_issue_detail_comment_form_has_hx_post
21 - test_issue_detail_close_button_has_hx_post
22 - test_issue_detail_reopen_button_has_hx_post
23
24 HTMX fragment
25 - test_issue_detail_htmx_request_returns_comment_fragment
26 """
27 from __future__ import annotations
28
29 import pytest
30 from httpx import AsyncClient
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from datetime import datetime, timezone
34
35 from muse.core.types import now_utc_iso
36 from musehub.core.genesis import compute_comment_id, compute_identity_id, compute_issue_id, compute_release_id, compute_repo_id
37 from musehub.db.musehub_release_models import MusehubRelease
38 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
39 from musehub.db.musehub_social_models import MusehubIssue, MusehubIssueComment
40 from musehub.types.json_types import StrDict
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 async def _make_repo(
49 db: AsyncSession,
50 owner: str = "songwriter",
51 slug: str = "melodies",
52 ) -> str:
53 """Seed a public repo and return its repo_id string."""
54 owner_id = compute_identity_id(owner.encode())
55 created_at = datetime.now(tz=timezone.utc)
56 repo = MusehubRepo(
57 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
58 name=slug,
59 owner=owner,
60 slug=slug,
61 visibility="public",
62 owner_user_id=owner_id,
63 created_at=created_at,
64 updated_at=created_at,
65 )
66 db.add(repo)
67 await db.commit()
68 await db.refresh(repo)
69 return str(repo.repo_id)
70
71
72 async def _make_issue(
73 db: AsyncSession,
74 repo_id: str,
75 *,
76 number: int = 1,
77 title: str = "Verse needs a bridge",
78 body: str = "The verse feels incomplete.",
79 state: str = "open",
80 author: str = "songwriter",
81 labels: list[str] | None = None,
82 symbol_anchors: list[str] | None = None,
83 ) -> MusehubIssue:
84 """Seed an issue and return it."""
85 author_id = compute_identity_id(author.encode())
86 now = datetime.now(tz=timezone.utc)
87 issue = MusehubIssue(
88 issue_id=compute_issue_id(repo_id, author_id, now.isoformat()),
89 repo_id=repo_id,
90 number=number,
91 title=title,
92 body=body,
93 state=state,
94 labels=labels or [],
95 symbol_anchors=symbol_anchors or [],
96 author=author,
97 )
98 db.add(issue)
99 await db.commit()
100 await db.refresh(issue)
101 return issue
102
103
104 async def _make_comment(
105 db: AsyncSession,
106 issue_id: str,
107 repo_id: str,
108 *,
109 author: str = "producer",
110 body: str = "Good point.",
111 parent_id: str | None = None,
112 ) -> MusehubIssueComment:
113 """Seed a comment and return it."""
114 author_id = compute_identity_id(author.encode())
115 comment = MusehubIssueComment(
116 comment_id=compute_comment_id(issue_id, author_id, now_utc_iso()),
117 issue_id=issue_id,
118 repo_id=repo_id,
119 author=author,
120 body=body,
121 parent_id=parent_id,
122 )
123 db.add(comment)
124 await db.commit()
125 await db.refresh(comment)
126 return comment
127
128
129 async def _get_detail(
130 client: AsyncClient,
131 number: int = 1,
132 owner: str = "songwriter",
133 slug: str = "melodies",
134 headers: StrDict | None = None,
135 ) -> tuple[int, str]:
136 """Fetch the issue detail page; return (status_code, body_text)."""
137 resp = await client.get(
138 f"/{owner}/{slug}/issues/{number}",
139 headers=headers or {},
140 )
141 return resp.status_code, resp.text
142
143
144 # ---------------------------------------------------------------------------
145 # Basic rendering
146 # ---------------------------------------------------------------------------
147
148
149 async def test_issue_detail_renders_title_server_side(
150 client: AsyncClient,
151 db_session: AsyncSession,
152 ) -> None:
153 """Issue title appears in the HTML rendered on the server."""
154 repo_id = await _make_repo(db_session)
155 await _make_issue(db_session, repo_id, title="Chorus hook is off-key")
156
157 status, body = await _get_detail(client)
158
159 assert status == 200
160 assert "Chorus hook is off-key" in body
161
162
163 async def test_issue_detail_unknown_number_404(
164 client: AsyncClient,
165 db_session: AsyncSession,
166 ) -> None:
167 """A non-existent issue number returns 404."""
168 await _make_repo(db_session)
169
170 resp = await client.get("/songwriter/melodies/issues/999")
171 assert resp.status_code == 404
172
173
174 # ---------------------------------------------------------------------------
175 # SSR body content
176 # ---------------------------------------------------------------------------
177
178
179 async def test_issue_detail_renders_body_markdown(
180 client: AsyncClient,
181 db_session: AsyncSession,
182 ) -> None:
183 """Issue body with Markdown bold is rendered as <strong> in the HTML."""
184 repo_id = await _make_repo(db_session)
185 await _make_issue(db_session, repo_id, body="The **bass line** needs work.")
186
187 status, body = await _get_detail(client)
188
189 assert status == 200
190 assert "<strong>bass line</strong>" in body
191
192
193 async def test_issue_detail_empty_body_shows_placeholder(
194 client: AsyncClient,
195 db_session: AsyncSession,
196 ) -> None:
197 """An issue with empty body renders the 'No description provided' placeholder."""
198 repo_id = await _make_repo(db_session)
199 await _make_issue(db_session, repo_id, body="")
200
201 status, body = await _get_detail(client)
202
203 assert status == 200
204 assert "No description provided" in body
205
206
207 # ---------------------------------------------------------------------------
208 # Comments
209 # ---------------------------------------------------------------------------
210
211
212 async def test_issue_detail_renders_comments_server_side(
213 client: AsyncClient,
214 db_session: AsyncSession,
215 ) -> None:
216 """A seeded comment body appears in the rendered HTML."""
217 repo_id = await _make_repo(db_session)
218 issue = await _make_issue(db_session, repo_id)
219 await _make_comment(db_session, issue.issue_id, repo_id, body="Agreed, bridge it up!")
220
221 status, body = await _get_detail(client)
222
223 assert status == 200
224 assert "Agreed, bridge it up!" in body
225
226
227 async def test_issue_detail_no_comments_shows_placeholder(
228 client: AsyncClient,
229 db_session: AsyncSession,
230 ) -> None:
231 """When there are no comments the placeholder text is rendered."""
232 repo_id = await _make_repo(db_session)
233 await _make_issue(db_session, repo_id)
234
235 status, body = await _get_detail(client)
236
237 assert status == 200
238 assert "No activity yet" in body
239
240
241 # ---------------------------------------------------------------------------
242 # HTMX attributes
243 # ---------------------------------------------------------------------------
244
245
246 async def test_issue_detail_cli_card_shown(
247 client: AsyncClient,
248 db_session: AsyncSession,
249 ) -> None:
250 """The 'Act via CLI' card is rendered with a muse hub issue snippet."""
251 repo_id = await _make_repo(db_session)
252 await _make_issue(db_session, repo_id)
253
254 status, body = await _get_detail(client)
255
256 assert status == 200
257 assert "muse hub issue" in body
258
259
260 async def test_issue_detail_open_state_shows_open_badge(
261 client: AsyncClient,
262 db_session: AsyncSession,
263 ) -> None:
264 """An open issue renders an Open state badge and filed-by attribution."""
265 repo_id = await _make_repo(db_session)
266 await _make_issue(db_session, repo_id, state="open")
267
268 status, body = await _get_detail(client)
269
270 assert status == 200
271 assert "Open" in body
272 assert "filed by" in body
273
274
275 async def test_issue_detail_closed_state_shows_closed_badge(
276 client: AsyncClient,
277 db_session: AsyncSession,
278 ) -> None:
279 """A closed issue renders a Closed state badge."""
280 repo_id = await _make_repo(db_session)
281 await _make_issue(db_session, repo_id, state="closed")
282
283 status, body = await _get_detail(client)
284
285 assert status == 200
286 assert "Closed" in body
287
288
289 # ---------------------------------------------------------------------------
290 # HTMX fragment
291 # ---------------------------------------------------------------------------
292
293
294 async def test_issue_detail_htmx_request_returns_comment_fragment(
295 client: AsyncClient,
296 db_session: AsyncSession,
297 ) -> None:
298 """GET with HX-Request: true returns the comment fragment (no full page shell)."""
299 repo_id = await _make_repo(db_session)
300 issue = await _make_issue(db_session, repo_id)
301 await _make_comment(db_session, issue.issue_id, repo_id, body="Fragment comment here.")
302
303 status, body = await _get_detail(client, headers={"HX-Request": "true"})
304
305 assert status == 200
306 assert "Fragment comment here." in body
307 # Fragment must not include the full page chrome
308 assert "<html" not in body
309 assert "<!DOCTYPE" not in body
310
311
312 # ---------------------------------------------------------------------------
313 # Symbol anchors (Phase 1A)
314 # ---------------------------------------------------------------------------
315
316
317 async def test_symbol_anchors_panel_shown_when_symbol_label_present(
318 client: AsyncClient,
319 db_session: AsyncSession,
320 ) -> None:
321 """Issues with symbol_anchors set display the Symbol Anchors panel."""
322 repo_id = await _make_repo(db_session)
323 await _make_issue(
324 db_session,
325 repo_id,
326 labels=["bug"],
327 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
328 )
329
330 status, body = await _get_detail(client)
331
332 assert status == 200
333 assert "Symbol Anchors" in body
334 assert "compute_snapshot_id" in body
335 assert "muse/core/snapshot.py" in body
336
337
338 async def test_symbol_labels_excluded_from_display_labels(
339 client: AsyncClient,
340 db_session: AsyncSession,
341 ) -> None:
342 """symbol_anchors appear in the anchors panel, not in the regular label list."""
343 repo_id = await _make_repo(db_session)
344 await _make_issue(
345 db_session,
346 repo_id,
347 labels=["performance"],
348 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
349 )
350
351 status, body = await _get_detail(client)
352
353 assert status == 200
354 # The 'performance' issue type appears in the page as its display name
355 assert "Performance" in body
356 # The raw symbol anchor address does NOT appear as a label chip (only in the anchors panel)
357 assert "symbol:muse/core/snapshot.py::compute_snapshot_id" not in body
358
359
360 async def test_symbol_anchors_panel_absent_without_symbol_labels(
361 client: AsyncClient,
362 db_session: AsyncSession,
363 ) -> None:
364 """Issues with no symbol: labels do not show the Symbol Anchors panel."""
365 repo_id = await _make_repo(db_session)
366 await _make_issue(db_session, repo_id, labels=["bug", "performance"])
367
368 status, body = await _get_detail(client)
369
370 assert status == 200
371 assert "Symbol Anchors" not in body
372
373
374 async def test_act_panel_shown(
375 client: AsyncClient,
376 db_session: AsyncSession,
377 ) -> None:
378 """The CLI/MCP/REST act panel is always present on the issue detail page."""
379 repo_id = await _make_repo(db_session)
380 await _make_issue(db_session, repo_id)
381
382 status, body = await _get_detail(client)
383
384 assert status == 200
385 assert "muse hub issue comment" in body
386 assert "create_issue" in body
387 assert "/issues/" in body
388
389
390 # ---------------------------------------------------------------------------
391 # Release card — Muse-native VCS graph release tracking
392 # ---------------------------------------------------------------------------
393
394 _COMMIT_ID_A = "a" * 64
395 _COMMIT_ID_B = "b" * 64
396
397
398 async def _make_commit(
399 db: AsyncSession,
400 repo_id: str,
401 commit_id: str,
402 *,
403 message: str = "fix: resolve the issue",
404 author: str = "gabriel",
405 branch: str = "dev",
406 timestamp: datetime | None = None,
407 ) -> MusehubCommit:
408 commit = MusehubCommit(
409 commit_id=commit_id,
410 branch=branch,
411 parent_ids=[],
412 message=message,
413 author=author,
414 timestamp=timestamp or datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc),
415 )
416 db.add(commit)
417 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
418 await db.commit()
419 await db.refresh(commit)
420 return commit
421
422
423 async def _make_release(
424 db: AsyncSession,
425 repo_id: str,
426 tag: str,
427 commit_id: str,
428 *,
429 semver_major: int = 0,
430 semver_minor: int = 2,
431 semver_patch: int = 1,
432 ) -> MusehubRelease:
433 rel = MusehubRelease(
434 release_id=compute_release_id(repo_id, tag, now_utc_iso()),
435 repo_id=repo_id,
436 tag=tag,
437 title=f"Release {tag}",
438 commit_id=commit_id,
439 semver_major=semver_major,
440 semver_minor=semver_minor,
441 semver_patch=semver_patch,
442 channel="stable",
443 )
444 db.add(rel)
445 await db.commit()
446 await db.refresh(rel)
447 return rel
448
449
450 async def test_release_card_no_commits_shows_placeholder(
451 client: AsyncClient,
452 db_session: AsyncSession,
453 ) -> None:
454 """Issue with no commit_anchors shows 'no commits linked' in the release card."""
455 repo_id = await _make_repo(db_session)
456 await _make_issue(db_session, repo_id)
457
458 status, body = await _get_detail(client)
459
460 assert status == 200
461 assert "Release" in body
462 assert "no commits linked" in body
463
464
465 async def test_release_card_shows_commit_hash_when_anchored(
466 client: AsyncClient,
467 db_session: AsyncSession,
468 ) -> None:
469 """Issue with a commit_anchor shows the short hash in the release card."""
470 repo_id = await _make_repo(db_session)
471 commit = await _make_commit(db_session, repo_id, _COMMIT_ID_A, message="fix: buffer overflow")
472 issue = await _make_issue(db_session, repo_id)
473 issue.commit_anchors = [commit.commit_id]
474 await db_session.commit()
475
476 status, body = await _get_detail(client)
477
478 assert status == 200
479 # Short hash (first 8 chars) visible in the release card
480 assert _COMMIT_ID_A[:8] in body
481 # Commit message rendered
482 assert "fix: buffer overflow" in body
483
484
485 async def test_release_card_shows_landed_tag_when_in_release(
486 client: AsyncClient,
487 db_session: AsyncSession,
488 ) -> None:
489 """When an anchor commit is in a tagged release, that release tag is shown."""
490 repo_id = await _make_repo(db_session)
491 # Anchor commit at T=1; release commit at T=2 (after) → anchor is contained.
492 anchor = await _make_commit(
493 db_session, repo_id, _COMMIT_ID_A,
494 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
495 )
496 rel_commit = await _make_commit(
497 db_session, repo_id, _COMMIT_ID_B,
498 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
499 )
500 await _make_release(db_session, repo_id, "v0.2.1", rel_commit.commit_id)
501 issue = await _make_issue(db_session, repo_id)
502 issue.commit_anchors = [anchor.commit_id]
503 await db_session.commit()
504
505 status, body = await _get_detail(client)
506
507 assert status == 200
508 assert "v0.2.1" in body
509
510
511 async def test_release_card_shows_next_tag_when_pending(
512 client: AsyncClient,
513 db_session: AsyncSession,
514 ) -> None:
515 """When commits exist but no release contains them, the next proposed tag is shown."""
516 repo_id = await _make_repo(db_session)
517 # Anchor commit is AFTER the release commit → not yet contained.
518 rel_commit = await _make_commit(
519 db_session, repo_id, _COMMIT_ID_B,
520 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
521 )
522 await _make_release(
523 db_session, repo_id, "v0.2.1", rel_commit.commit_id,
524 semver_major=0, semver_minor=2, semver_patch=1,
525 )
526 anchor = await _make_commit(
527 db_session, repo_id, _COMMIT_ID_A,
528 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
529 )
530 issue = await _make_issue(db_session, repo_id)
531 issue.commit_anchors = [anchor.commit_id]
532 await db_session.commit()
533
534 status, body = await _get_detail(client)
535
536 assert status == 200
537 # Proposed next patch release tag visible
538 assert "v0.2.2" in body
539 assert "proposed" in body
540
541
542 # ---------------------------------------------------------------------------
543 # Intelligence panel — singularity mode (Phase 2B)
544 # ---------------------------------------------------------------------------
545
546
547 async def test_intel_panel_shows_symbol_header(
548 client: AsyncClient,
549 db_session: AsyncSession,
550 ) -> None:
551 """Symbol anchors extracted from labels appear in the Symbol Anchors panel."""
552 repo_id = await _make_repo(db_session)
553 await _make_issue(
554 db_session,
555 repo_id,
556 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
557 )
558
559 status, body = await _get_detail(client)
560
561 assert status == 200
562 # Symbol name rendered in the Symbol Anchors panel (not the Intelligence panel,
563 # which only appears when intel data is indexed for the repo).
564 assert "compute_snapshot_id" in body
565
566
567 async def test_intel_panel_not_yet_indexed_hint_shown(
568 client: AsyncClient,
569 db_session: AsyncSession,
570 ) -> None:
571 """When no symbol index exists, the symbol anchor still appears in the anchors panel."""
572 repo_id = await _make_repo(db_session)
573 await _make_issue(
574 db_session,
575 repo_id,
576 labels=["symbol:muse/core/snapshot.py::build_manifest"],
577 )
578
579 status, body = await _get_detail(client)
580
581 assert status == 200
582 # The symbol address is shown in the Symbol Anchors panel; the Intelligence
583 # panel is hidden when no index exists (no placeholder text is shown).
584 assert "build_manifest" in body
585
586
587 async def test_intel_panel_shows_blast_radius_section(
588 client: AsyncClient,
589 db_session: AsyncSession,
590 ) -> None:
591 """The Intelligence panel renders the Blast radius section."""
592 repo_id = await _make_repo(db_session)
593 await _make_issue(
594 db_session,
595 repo_id,
596 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
597 )
598
599 status, body = await _get_detail(client)
600
601 assert status == 200
602 assert "Blast radius" in body
603
604
605 async def test_intel_panel_shows_open_issues_section(
606 client: AsyncClient,
607 db_session: AsyncSession,
608 ) -> None:
609 """Symbol Anchors panel renders the anchor address for each anchored symbol."""
610 repo_id = await _make_repo(db_session)
611 await _make_issue(
612 db_session,
613 repo_id,
614 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
615 )
616
617 status, body = await _get_detail(client)
618
619 assert status == 200
620 # The anchors panel lists the symbol path — Open issues section was
621 # removed from the Intelligence panel in the flattening refactor.
622 assert "snapshot.py" in body
623
624
625 async def test_intel_panel_open_issues_lists_current_issue_number(
626 client: AsyncClient,
627 db_session: AsyncSession,
628 ) -> None:
629 """The Open issues section includes the current issue's own number as #N."""
630 repo_id = await _make_repo(db_session)
631 await _make_issue(
632 db_session,
633 repo_id,
634 number=7,
635 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
636 state="open",
637 )
638
639 status, body = await _get_detail(client, number=7)
640
641 assert status == 200
642 assert "#7" in body
643
644
645 async def test_intel_placeholder_shown_when_no_intel(
646 client: AsyncClient,
647 db_session: AsyncSession,
648 ) -> None:
649 """Page renders correctly when symbol anchors exist but no intel index is built."""
650 repo_id = await _make_repo(db_session)
651 await _make_issue(
652 db_session,
653 repo_id,
654 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
655 )
656
657 status, body = await _get_detail(client)
658
659 assert status == 200
660 # Symbol anchor appears in the Symbol Anchors panel; the Intelligence panel
661 # is hidden when no index exists (the flattened design omits the placeholder).
662 assert "compute_snapshot_id" in body
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago