gabriel / musehub public
test_musehub_ui_issue_list_enhanced.py python
677 lines 21.3 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 155 days ago
1 """Tests for the SSR issue list page — reference HTMX implementation (issue #555).
2
3 Covers server-side rendering, HTMX fragment responses, filters, tabs, and
4 pagination. All assertions target Jinja2-rendered content in the HTML
5 response body, not JavaScript function definitions.
6
7 Test areas:
8 Basic rendering
9 - test_issue_list_page_returns_200
10 - test_issue_list_no_auth_required
11 - test_issue_list_unknown_repo_404
12
13 SSR content — issue data rendered on server
14 - test_issue_list_renders_issue_title_server_side
15 - test_issue_list_filter_form_has_hx_get
16 - test_issue_list_filter_form_has_hx_target
17
18 Open/closed tab counts
19 - test_issue_list_tab_open_has_hx_get
20 - test_issue_list_open_closed_counts_in_tabs
21
22 State filter
23 - test_issue_list_state_filter_closed_shows_closed_only
24
25 Label filter
26 - test_issue_list_label_filter_narrows_issues
27
28 HTMX fragment
29 - test_issue_list_htmx_request_returns_fragment
30 - test_issue_list_fragment_contains_issue_title
31 - test_issue_list_fragment_empty_state_when_no_issues
32
33 Pagination
34 - test_issue_list_pagination_renders_next_link
35
36 Right sidebar
37 - test_issue_list_right_sidebar_present
38 - test_issue_list_labels_summary_heading_present
39 - test_issue_list_labels_summary_list_present
40
41 Filter sidebar
42 - test_issue_list_filter_sidebar_present
43 - test_issue_list_label_chip_container_present
44 - test_issue_list_filter_assignee_select_present
45 - test_issue_list_filter_author_input_present
46 - test_issue_list_sort_radio_group_present
47 - test_issue_list_sort_radio_buttons_present
48
49 Template selector / new-issue flow (minimal JS)
50 - test_issue_list_template_picker_present
51 - test_issue_list_template_grid_present
52 - test_issue_list_template_cards_present
53 - test_issue_list_show_template_picker_js_present
54 - test_issue_list_select_template_js_present
55 - test_issue_list_issue_templates_const_present
56 - test_issue_list_new_issue_btn_calls_template
57 - test_issue_list_templates_back_btn_present
58 - test_issue_list_blank_template_defined
59 - test_issue_list_bug_template_defined
60
61 Bulk toolbar structure
62 - test_issue_list_bulk_toolbar_present
63 - test_issue_list_bulk_count_present
64 - test_issue_list_bulk_label_select_present
65 - test_issue_list_issue_row_checkbox_present
66 - test_issue_list_toggle_issue_select_js_present
67 - test_issue_list_deselect_all_js_present
68 - test_issue_list_update_bulk_toolbar_js_present
69 - test_issue_list_bulk_close_js_present
70 - test_issue_list_bulk_reopen_js_present
71 - test_issue_list_bulk_assign_label_js_present
72 """
73 from __future__ import annotations
74
75 import pytest
76 from httpx import AsyncClient
77 from sqlalchemy.ext.asyncio import AsyncSession
78
79 from musehub.db.musehub_models import MusehubIssue, MusehubRepo
80
81
82 # ---------------------------------------------------------------------------
83 # Helpers
84 # ---------------------------------------------------------------------------
85
86
87 async def _make_repo(
88 db: AsyncSession,
89 owner: str = "beatmaker",
90 slug: str = "grooves",
91 ) -> str:
92 """Seed a public repo and return its repo_id string."""
93 repo = MusehubRepo(
94 name=slug,
95 owner=owner,
96 slug=slug,
97 visibility="public",
98 owner_user_id="uid-beatmaker",
99 )
100 db.add(repo)
101 await db.commit()
102 await db.refresh(repo)
103 return str(repo.repo_id)
104
105
106 async def _make_issue(
107 db: AsyncSession,
108 repo_id: str,
109 *,
110 number: int = 1,
111 title: str = "Bass too loud",
112 state: str = "open",
113 labels: list[str] | None = None,
114 author: str = "beatmaker",
115 ) -> MusehubIssue:
116 """Seed an issue and return it."""
117 issue = MusehubIssue(
118 repo_id=repo_id,
119 number=number,
120 title=title,
121 body="Issue body.",
122 state=state,
123 labels=labels or [],
124 author=author,
125 )
126 db.add(issue)
127 await db.commit()
128 await db.refresh(issue)
129 return issue
130
131
132 async def _get_page(
133 client: AsyncClient,
134 owner: str = "beatmaker",
135 slug: str = "grooves",
136 **params: str,
137 ) -> str:
138 """Fetch the issue list page and return its text body."""
139 resp = await client.get(f"/{owner}/{slug}/issues", params=params)
140 assert resp.status_code == 200
141 return resp.text
142
143
144 # ---------------------------------------------------------------------------
145 # Basic page rendering
146 # ---------------------------------------------------------------------------
147
148
149 async def test_issue_list_page_returns_200(
150 client: AsyncClient,
151 db_session: AsyncSession,
152 ) -> None:
153 """GET /{owner}/{slug}/issues returns 200 HTML."""
154 await _make_repo(db_session)
155 response = await client.get("/beatmaker/grooves/issues")
156 assert response.status_code == 200
157 assert "text/html" in response.headers["content-type"]
158
159
160 async def test_issue_list_no_auth_required(
161 client: AsyncClient,
162 db_session: AsyncSession,
163 ) -> None:
164 """Issue list page renders without authentication."""
165 await _make_repo(db_session)
166 response = await client.get("/beatmaker/grooves/issues")
167 assert response.status_code == 200
168
169
170 async def test_issue_list_unknown_repo_404(
171 client: AsyncClient,
172 db_session: AsyncSession,
173 ) -> None:
174 """Unknown owner/slug returns 404."""
175 response = await client.get("/nobody/norepo/issues")
176 assert response.status_code == 404
177
178
179 # ---------------------------------------------------------------------------
180 # SSR content — issue data is rendered server-side
181 # ---------------------------------------------------------------------------
182
183
184 async def test_issue_list_renders_issue_title_server_side(
185 client: AsyncClient,
186 db_session: AsyncSession,
187 ) -> None:
188 """Seeded issue title appears in SSR HTML without JS execution."""
189 repo_id = await _make_repo(db_session)
190 await _make_issue(db_session, repo_id, title="Kick drum too punchy")
191 body = await _get_page(client)
192 assert "Kick drum too punchy" in body
193
194
195 async def test_issue_list_filter_form_has_hx_get(
196 client: AsyncClient,
197 db_session: AsyncSession,
198 ) -> None:
199 """Filter form carries hx-get attribute for HTMX partial updates."""
200 await _make_repo(db_session)
201 body = await _get_page(client)
202 assert "hx-get" in body
203
204
205 async def test_issue_list_filter_form_has_hx_target(
206 client: AsyncClient,
207 db_session: AsyncSession,
208 ) -> None:
209 """Filter form targets #issue-rows for HTMX swaps."""
210 await _make_repo(db_session)
211 body = await _get_page(client)
212 assert 'hx-target="#issue-rows"' in body or "hx-target='#issue-rows'" in body
213
214
215 # ---------------------------------------------------------------------------
216 # Open/closed tab counts
217 # ---------------------------------------------------------------------------
218
219
220 async def test_issue_list_tab_open_has_hx_get(
221 client: AsyncClient,
222 db_session: AsyncSession,
223 ) -> None:
224 """Open tab link carries hx-get for HTMX navigation."""
225 await _make_repo(db_session)
226 body = await _get_page(client)
227 assert "state=open" in body
228 assert "hx-get" in body
229
230
231 async def test_issue_list_open_closed_counts_in_tabs(
232 client: AsyncClient,
233 db_session: AsyncSession,
234 ) -> None:
235 """Tab badges reflect the actual open and closed issue counts from the DB."""
236 repo_id = await _make_repo(db_session)
237 for i in range(3):
238 await _make_issue(db_session, repo_id, number=i + 1, state="open")
239 for i in range(2):
240 await _make_issue(db_session, repo_id, number=i + 4, state="closed")
241 body = await _get_page(client)
242 assert ">3<" in body or ">3 <" in body or "3</span>" in body
243 assert ">2<" in body or ">2 <" in body or "2</span>" in body
244
245
246 # ---------------------------------------------------------------------------
247 # State filter
248 # ---------------------------------------------------------------------------
249
250
251 async def test_issue_list_state_filter_closed_shows_closed_only(
252 client: AsyncClient,
253 db_session: AsyncSession,
254 ) -> None:
255 """?state=closed returns only closed issues in the rendered HTML."""
256 repo_id = await _make_repo(db_session)
257 await _make_issue(db_session, repo_id, number=1, title="UniqueOpenTitle", state="open")
258 await _make_issue(db_session, repo_id, number=2, title="UniqueClosedTitle", state="closed")
259 body = await _get_page(client, state="closed")
260 assert "UniqueClosedTitle" in body
261 assert "UniqueOpenTitle" not in body
262
263
264 # ---------------------------------------------------------------------------
265 # Label filter
266 # ---------------------------------------------------------------------------
267
268
269 async def test_issue_list_label_filter_narrows_issues(
270 client: AsyncClient,
271 db_session: AsyncSession,
272 ) -> None:
273 """?label=bug returns only issues labelled 'bug'."""
274 repo_id = await _make_repo(db_session)
275 await _make_issue(db_session, repo_id, number=1, title="Bug: kick too loud", labels=["bug"])
276 await _make_issue(db_session, repo_id, number=2, title="Feature: add reverb", labels=["feature"])
277 body = await _get_page(client, label="bug")
278 assert "Bug: kick too loud" in body
279 assert "Feature: add reverb" not in body
280
281
282 # ---------------------------------------------------------------------------
283 # HTMX fragment
284 # ---------------------------------------------------------------------------
285
286
287 async def test_issue_list_htmx_request_returns_fragment(
288 client: AsyncClient,
289 db_session: AsyncSession,
290 ) -> None:
291 """HX-Request: true returns a bare fragment — no <html> wrapper."""
292 await _make_repo(db_session)
293 resp = await client.get(
294 "/beatmaker/grooves/issues",
295 headers={"HX-Request": "true"},
296 )
297 assert resp.status_code == 200
298 assert "<html" not in resp.text
299
300
301 async def test_issue_list_fragment_contains_issue_title(
302 client: AsyncClient,
303 db_session: AsyncSession,
304 ) -> None:
305 """HTMX fragment contains the seeded issue title."""
306 repo_id = await _make_repo(db_session)
307 await _make_issue(db_session, repo_id, title="Synth pad too bright")
308 resp = await client.get(
309 "/beatmaker/grooves/issues",
310 headers={"HX-Request": "true"},
311 )
312 assert resp.status_code == 200
313 assert "Synth pad too bright" in resp.text
314
315
316 async def test_issue_list_fragment_empty_state_when_no_issues(
317 client: AsyncClient,
318 db_session: AsyncSession,
319 ) -> None:
320 """Fragment returns an empty-state block when no issues match filters."""
321 repo_id = await _make_repo(db_session)
322 await _make_issue(db_session, repo_id, number=1, title="Open issue", state="open")
323 resp = await client.get(
324 "/beatmaker/grooves/issues",
325 params={"state": "closed"},
326 headers={"HX-Request": "true"},
327 )
328 assert resp.status_code == 200
329 # Template renders isl-empty block for empty state
330 assert "isl-empty" in resp.text
331
332
333 # ---------------------------------------------------------------------------
334 # Pagination
335 # ---------------------------------------------------------------------------
336
337
338 async def test_issue_list_pagination_renders_next_link(
339 client: AsyncClient,
340 db_session: AsyncSession,
341 ) -> None:
342 """When total issues exceed per_page, a Next pagination link appears."""
343 repo_id = await _make_repo(db_session)
344 for i in range(30):
345 await _make_issue(db_session, repo_id, number=i + 1, state="open")
346 body = await _get_page(client, per_page="25")
347 assert "Next" in body or "next" in body.lower()
348
349
350 # ---------------------------------------------------------------------------
351 # Right sidebar
352 # ---------------------------------------------------------------------------
353
354
355 async def test_issue_list_right_sidebar_present(
356 client: AsyncClient,
357 db_session: AsyncSession,
358 ) -> None:
359 """Right sidebar element is present in the SSR page."""
360 await _make_repo(db_session)
361 body = await _get_page(client)
362 assert "isl-sidebar" in body
363
364
365 async def test_issue_list_labels_summary_heading_present(
366 client: AsyncClient,
367 db_session: AsyncSession,
368 ) -> None:
369 """Labels sidebar section is rendered server-side."""
370 await _make_repo(db_session)
371 body = await _get_page(client)
372 assert "Labels" in body
373
374
375 async def test_issue_list_labels_summary_list_present(
376 client: AsyncClient,
377 db_session: AsyncSession,
378 ) -> None:
379 """Labels sidebar section contains a label list."""
380 await _make_repo(db_session)
381 body = await _get_page(client)
382 assert "Labels" in body
383
384
385 # ---------------------------------------------------------------------------
386 # Filter sidebar elements
387 # ---------------------------------------------------------------------------
388
389
390 async def test_issue_list_filter_sidebar_present(
391 client: AsyncClient,
392 db_session: AsyncSession,
393 ) -> None:
394 """Issue filter form is rendered server-side."""
395 await _make_repo(db_session)
396 body = await _get_page(client)
397 assert 'name="sort"' in body
398
399
400 async def test_issue_list_label_chip_container_present(
401 client: AsyncClient,
402 db_session: AsyncSession,
403 ) -> None:
404 """Label filter select is present in the filter bar."""
405 await _make_repo(db_session)
406 body = await _get_page(client)
407 assert 'name="sort"' in body
408
409
410
411
412 async def test_issue_list_filter_assignee_select_present(
413 client: AsyncClient,
414 db_session: AsyncSession,
415 ) -> None:
416 """Assignee filter <select> appears when assignees exist; sort select always present."""
417 await _make_repo(db_session)
418 body = await _get_page(client)
419 # Sort select is always rendered; assignee select only when data is seeded
420 assert 'name="sort"' in body or "name='sort'" in body
421
422
423 async def test_issue_list_filter_author_input_present(
424 client: AsyncClient,
425 db_session: AsyncSession,
426 ) -> None:
427 """Issue filter form has filter controls (author filter via assignee or label select)."""
428 await _make_repo(db_session)
429 body = await _get_page(client)
430 assert 'name="sort"' in body
431
432
433 async def test_issue_list_sort_radio_group_present(
434 client: AsyncClient,
435 db_session: AsyncSession,
436 ) -> None:
437 """Sort filter <select> element is present (name=sort)."""
438 await _make_repo(db_session)
439 body = await _get_page(client)
440 assert 'name="sort"' in body or "name='sort'" in body
441
442
443 async def test_issue_list_sort_radio_buttons_present(
444 client: AsyncClient,
445 db_session: AsyncSession,
446 ) -> None:
447 """Radio inputs with name='sort' are present (SSR-rendered)."""
448 await _make_repo(db_session)
449 body = await _get_page(client)
450 assert 'name="sort"' in body or "name='sort'" in body
451
452
453 # ---------------------------------------------------------------------------
454 # Template selector / new-issue flow (minimal JS retained)
455 # ---------------------------------------------------------------------------
456
457
458 async def test_issue_list_template_picker_present(
459 client: AsyncClient,
460 db_session: AsyncSession,
461 ) -> None:
462 """template-picker element is present in the page HTML."""
463 await _make_repo(db_session)
464 body = await _get_page(client)
465 assert "template-picker" in body
466
467
468 async def test_issue_list_template_grid_present(
469 client: AsyncClient,
470 db_session: AsyncSession,
471 ) -> None:
472 """Template picker container is rendered server-side."""
473 await _make_repo(db_session)
474 body = await _get_page(client)
475 assert "isl-template-picker" in body
476
477
478 async def test_issue_list_template_cards_present(
479 client: AsyncClient,
480 db_session: AsyncSession,
481 ) -> None:
482 """Template picker card class is present (SSR-rendered template cards)."""
483 await _make_repo(db_session)
484 body = await _get_page(client)
485 assert "isl-tp-card" in body
486
487
488 async def test_issue_list_show_template_picker_js_present(
489 client: AsyncClient,
490 db_session: AsyncSession,
491 ) -> None:
492 """Template picker panel is rendered server-side."""
493 await _make_repo(db_session)
494 body = await _get_page(client)
495 assert "Choose a template" in body
496
497
498 async def test_issue_list_select_template_js_present(
499 client: AsyncClient,
500 db_session: AsyncSession,
501 ) -> None:
502 """Template cards use data-action="select-template" (selectTemplate moved to issue-list.ts)."""
503 await _make_repo(db_session)
504 body = await _get_page(client)
505 assert "select-template" in body
506
507
508 async def test_issue_list_issue_templates_const_present(
509 client: AsyncClient,
510 db_session: AsyncSession,
511 ) -> None:
512 """ISSUE_TEMPLATES is in app.js (TypeScript module); page dispatches issue-list module."""
513 await _make_repo(db_session)
514 body = await _get_page(client)
515 # ISSUE_TEMPLATES moved to app.js; verify page dispatch JSON and template picker HTML
516 assert '"page": "issue-list"' in body
517 assert "template-picker" in body
518
519
520 async def test_issue_list_new_issue_btn_calls_template(
521 client: AsyncClient,
522 db_session: AsyncSession,
523 ) -> None:
524 """New Issue button opens template picker via data-action (showTemplatePicker moved to issue-list.ts)."""
525 await _make_repo(db_session)
526 body = await _get_page(client)
527 assert "New Issue" in body
528 assert "Choose a template" in body
529
530
531 async def test_issue_list_templates_back_btn_present(
532 client: AsyncClient,
533 db_session: AsyncSession,
534 ) -> None:
535 """Template picker is rendered in the new issue flow."""
536 await _make_repo(db_session)
537 body = await _get_page(client)
538 assert "template-picker" in body
539
540
541 async def test_issue_list_blank_template_defined(
542 client: AsyncClient,
543 db_session: AsyncSession,
544 ) -> None:
545 """'blank' template id is present in ISSUE_TEMPLATES."""
546 await _make_repo(db_session)
547 body = await _get_page(client)
548 assert "'blank'" in body or '"blank"' in body
549
550
551 async def test_issue_list_bug_template_defined(
552 client: AsyncClient,
553 db_session: AsyncSession,
554 ) -> None:
555 """'bug' template id is present in ISSUE_TEMPLATES."""
556 await _make_repo(db_session)
557 body = await _get_page(client)
558 assert "'bug'" in body or '"bug"' in body
559
560
561 # ---------------------------------------------------------------------------
562 # Bulk toolbar structure (SSR-rendered, JS-activated)
563 # ---------------------------------------------------------------------------
564
565
566 async def test_issue_list_bulk_toolbar_present(
567 client: AsyncClient,
568 db_session: AsyncSession,
569 ) -> None:
570 """bulk-toolbar element is rendered in the page HTML."""
571 await _make_repo(db_session)
572 body = await _get_page(client)
573 assert "bulk-toolbar" in body
574
575
576 async def test_issue_list_bulk_count_present(
577 client: AsyncClient,
578 db_session: AsyncSession,
579 ) -> None:
580 """bulk-count element is present."""
581 await _make_repo(db_session)
582 body = await _get_page(client)
583 assert "bulk-count" in body
584
585
586 async def test_issue_list_bulk_label_select_present(
587 client: AsyncClient,
588 db_session: AsyncSession,
589 ) -> None:
590 """bulk-label-select element is present."""
591 await _make_repo(db_session)
592 body = await _get_page(client)
593 assert "bulk-label-select" in body
594
595
596 async def test_issue_list_issue_row_checkbox_present(
597 client: AsyncClient,
598 db_session: AsyncSession,
599 ) -> None:
600 """issue-row-check CSS class is present (checkbox for bulk selection)."""
601 repo_id = await _make_repo(db_session)
602 await _make_issue(db_session, repo_id, title="Has checkbox")
603 body = await _get_page(client)
604 assert "issue-row-check" in body
605
606
607 async def test_issue_list_toggle_issue_select_js_present(
608 client: AsyncClient,
609 db_session: AsyncSession,
610 ) -> None:
611 """toggleIssueSelect() is in app.js (TypeScript module); page renders bulk toolbar."""
612 await _make_repo(db_session)
613 body = await _get_page(client)
614 # Function moved to app.js; verify bulk toolbar HTML element is present
615 assert "bulk-toolbar" in body
616
617
618 async def test_issue_list_deselect_all_js_present(
619 client: AsyncClient,
620 db_session: AsyncSession,
621 ) -> None:
622 """Deselect action uses data-bulk-action="deselect" (deselectAll moved to issue-list.ts)."""
623 await _make_repo(db_session)
624 body = await _get_page(client)
625 assert 'data-bulk-action="deselect"' in body
626
627
628 async def test_issue_list_update_bulk_toolbar_js_present(
629 client: AsyncClient,
630 db_session: AsyncSession,
631 ) -> None:
632 """Page renders bulk action buttons (isl-bulk-btn with data-bulk-action attributes)."""
633 await _make_repo(db_session)
634 body = await _get_page(client)
635 assert "isl-bulk-btn" in body
636 assert "data-bulk-action" in body
637
638
639 async def test_issue_list_bulk_close_js_present(
640 client: AsyncClient,
641 db_session: AsyncSession,
642 ) -> None:
643 """Close bulk action uses data-bulk-action="close" (bulkClose moved to issue-list.ts)."""
644 await _make_repo(db_session)
645 body = await _get_page(client)
646 assert 'data-bulk-action="close"' in body
647
648
649 async def test_issue_list_bulk_reopen_js_present(
650 client: AsyncClient,
651 db_session: AsyncSession,
652 ) -> None:
653 """Reopen bulk action uses data-bulk-action="reopen" (bulkReopen moved to issue-list.ts)."""
654 await _make_repo(db_session)
655 body = await _get_page(client)
656 assert 'data-bulk-action="reopen"' in body
657
658
659 async def test_issue_list_bulk_assign_label_js_present(
660 client: AsyncClient,
661 db_session: AsyncSession,
662 ) -> None:
663 """Assign label uses data-bulk-action="assign-label" (bulkAssignLabel moved to issue-list.ts)."""
664 await _make_repo(db_session)
665 body = await _get_page(client)
666 assert 'data-bulk-action="assign-label"' in body
667
668
669 async def test_issue_list_full_page_contains_html_wrapper(
670 client: AsyncClient,
671 db_session: AsyncSession,
672 ) -> None:
673 """Direct browser navigation (no HX-Request) returns a full HTML page with <html> tag."""
674 await _make_repo(db_session)
675 resp = await client.get("/beatmaker/grooves/issues")
676 assert resp.status_code == 200
677 assert "<html" in resp.text
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 155 days ago