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