Skip to main content

max / makenotwork

19.7 KB · 622 lines History Blame Raw
1 //! HTMX integration tests: verify that HTMX-aware routes return the expected
2 //! HTML fragments, headers, and status codes.
3
4 use crate::harness::TestHarness;
5 use serde_json::Value;
6 use uuid::Uuid;
7
8 // Dashboard Tabs
9
10 #[tokio::test]
11 async fn dashboard_tabs_return_html_fragments() {
12 let mut h = TestHarness::new().await;
13 let _user_id = h.signup("tabuser", "tab@example.com", "password123").await;
14
15 let tabs = ["details", "payments", "projects", "creator"];
16 for tab in tabs {
17 let resp = h.client.htmx_get(&format!("/dashboard/tabs/{tab}")).await;
18 assert_eq!(
19 resp.status, 200,
20 "Dashboard tab '{}' should return 200, got {}",
21 tab, resp.status
22 );
23 // Each tab returns an HTML fragment (not a full page with <html>)
24 assert!(
25 !resp.text.contains("<!DOCTYPE"),
26 "Tab '{tab}' should return a fragment, not a full page"
27 );
28 // Should contain some HTML content
29 assert!(
30 resp.text.contains('<'),
31 "Tab '{tab}' should contain HTML markup"
32 );
33 }
34 }
35
36 #[tokio::test]
37 async fn project_tabs_return_html_fragments() {
38 let mut h = TestHarness::new().await;
39 let setup = h.create_creator_with_item("htmxuser", "audio", 500).await;
40 let slug = setup.slug;
41
42 let tabs = [
43 "overview",
44 "content",
45 "analytics",
46 "settings",
47 // "blog" was here until 2026-08-26. The dashboard blog tab was dead
48 // markup over a route nothing reached, and it went at the
49 // project_content flip (`6077d0d9`).
50 "subscriptions",
51 ];
52 for tab in tabs {
53 let resp = h
54 .client
55 .htmx_get(&format!("/dashboard/project/{slug}/tabs/{tab}"))
56 .await;
57 assert_eq!(
58 resp.status, 200,
59 "Project tab '{}' should return 200, got {}",
60 tab, resp.status
61 );
62 assert!(
63 !resp.text.contains("<!DOCTYPE"),
64 "Project tab '{tab}' should return a fragment, not a full page"
65 );
66 assert!(
67 resp.text.contains('<'),
68 "Project tab '{tab}' should contain HTML markup"
69 );
70 }
71 }
72
73 /// A tab URL typed, bookmarked or crawled is a navigation, not an hx-get, and
74 /// serving the bare partial for it showed chromeless HTML to a human and let a
75 /// crawler index a fragment as a page. It redirects to the page the fragment
76 /// belongs to instead. Replaces the older assertion that a plain GET returned
77 /// 200 with the partial, which is the behaviour that was wrong.
78 #[tokio::test]
79 async fn dashboard_tab_without_htmx_redirects_to_the_dashboard() {
80 let mut h = TestHarness::new().await;
81 let _user_id = h
82 .signup("nohtmx", "nohtmx@example.com", "password123")
83 .await;
84
85 let resp = h.client.get("/dashboard/tabs/profile").await;
86 assert_eq!(
87 resp.status, 302,
88 "plain GET of a tab should redirect, got {}",
89 resp.status
90 );
91 assert_eq!(
92 resp.headers
93 .get("location")
94 .and_then(|v| v.to_str().ok())
95 .unwrap_or_default(),
96 "/dashboard"
97 );
98 }
99
100 /// The other three fragment families redirect the same way. `/discover/results`
101 /// and `/pricing/compare` are public, so they need no session to check.
102 #[tokio::test]
103 async fn public_fragments_without_htmx_redirect_to_their_page() {
104 let mut h = TestHarness::new().await;
105
106 for (fragment, parent) in [
107 ("/discover/results?mode=items", "/discover"),
108 ("/pricing/compare", "/pricing"),
109 ] {
110 let resp = h.client.get(fragment).await;
111 assert_eq!(resp.status, 302, "{fragment} should redirect");
112 assert_eq!(
113 resp.headers
114 .get("location")
115 .and_then(|v| v.to_str().ok())
116 .unwrap_or_default(),
117 parent,
118 "{fragment} redirected somewhere unexpected"
119 );
120 }
121 }
122
123 /// The redirect is GET-only. A POST to a fragment endpoint carries a write the
124 /// caller is waiting on, and redirecting it would swallow the submission.
125 /// Asserted against a route that exists rather than a hypothetical one: the
126 /// check here is only that the middleware did not turn it into a 302.
127 #[tokio::test]
128 async fn fragment_redirect_does_not_touch_post() {
129 let mut h = TestHarness::new().await;
130 h.client.fetch_csrf_token().await;
131
132 let resp = h.client.post_form("/discover/results", "mode=items").await;
133 assert_ne!(
134 resp.status, 302,
135 "POST to a fragment path must not be redirected by the fragment guard"
136 );
137 }
138
139 #[tokio::test]
140 async fn dashboard_requires_auth() {
141 let mut h = TestHarness::new().await;
142
143 // No login: HTMX GET to dashboard tab should return 401 (Unauthorized)
144 // Need to establish a session first for CSRF
145 h.client.fetch_csrf_token().await;
146
147 let resp = h.client.htmx_get("/dashboard/tabs/profile").await;
148 assert_eq!(
149 resp.status, 401,
150 "Unauthenticated HTMX tab request should return 401, got {}",
151 resp.status
152 );
153 }
154
155 // Discover
156
157 #[tokio::test]
158 async fn discover_results_returns_html() {
159 let mut h = TestHarness::new().await;
160
161 let resp = h.client.htmx_get("/discover/results").await;
162 assert_eq!(resp.status, 200, "Discover results should return 200");
163 // The partial includes results-container markup
164 assert!(
165 resp.text.contains("results-container") || resp.text.contains("results-table"),
166 "Discover results should contain results HTML"
167 );
168 }
169
170 #[tokio::test]
171 async fn discover_results_pushes_the_full_page_url() {
172 let mut h = TestHarness::new().await;
173
174 let resp = h
175 .client
176 .htmx_get("/discover/results?mode=items&item_type=preset")
177 .await;
178 assert_eq!(resp.status, 200);
179 assert_eq!(
180 resp.header("HX-Push-Url"),
181 Some("/discover?mode=items&item_type=preset"),
182 "A filter click should push the full page URL, not the partial's URL"
183 );
184 }
185
186 #[tokio::test]
187 async fn discover_results_drops_blank_filters_from_the_pushed_url() {
188 let mut h = TestHarness::new().await;
189
190 // hx-include sends every .discover-filter on every request, so most arrive blank.
191 let resp = h
192 .client
193 .htmx_get("/discover/results?q=&tag=&category=&ai_tier=&min_price=&max_price=&mode=items&item_type=preset")
194 .await;
195 assert_eq!(resp.status, 200);
196 assert_eq!(
197 resp.header("HX-Push-Url"),
198 Some("/discover?mode=items&item_type=preset"),
199 "Blank filters must not be echoed into the address bar"
200 );
201 }
202
203 #[tokio::test]
204 async fn discover_search_replaces_rather_than_pushes_history() {
205 let mut h = TestHarness::new().await;
206
207 // The search box is debounced, so pushing would leave an entry per keystroke pause.
208 //
209 // `div` and not `input#search-input`: the box is a described Field now, and
210 // a consult hangs its route on a wrapper around the control, which carries
211 // no id of its own. See `discover_results`.
212 let resp = h
213 .client
214 .request_with_headers(
215 "GET",
216 "/discover/results?mode=items&q=ambient",
217 None,
218 &[("HX-Request", "true"), ("HX-Source", "div")],
219 )
220 .await;
221 assert_eq!(resp.status, 200);
222 assert_eq!(
223 resp.header("HX-Replace-Url"),
224 Some("/discover?mode=items&q=ambient"),
225 "Typing in the search box should replace the URL"
226 );
227 assert_eq!(
228 resp.header("HX-Push-Url"),
229 None,
230 "Typing must not push a history entry"
231 );
232 }
233
234 #[tokio::test]
235 async fn discover_filters_are_real_form_controls() {
236 let mut h = TestHarness::new().await;
237
238 // The type facet renders one control per type that has items, so an empty
239 // catalog would render no checkboxes and the assertions below would pass
240 // vacuously or fail confusingly. Seed one discoverable item first.
241 let setup = h.create_creator_with_item("formctl", "audio", 1000).await;
242 sqlx::query(
243 "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \
244 deleted_at = NULL WHERE id = $1::uuid",
245 )
246 .bind(&setup.item_id)
247 .execute(&h.db)
248 .await
249 .expect("publish item");
250 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
251 .bind(&setup.project_id)
252 .execute(&h.db)
253 .await
254 .expect("publish project");
255
256 let resp = h.client.get("/discover?mode=items").await;
257 assert_eq!(resp.status, 200);
258
259 // The item-mode filters are now checkboxes and radios rather than buttons
260 // carrying hx-vals. Native controls are what make multi-select expressible
261 // at all, and they are keyboard-operable with no scripting: the earlier
262 // li[role=option][tabindex=0] took focus but could not be activated, and
263 // the .filter-btn that replaced it still needed JS to mirror its value into
264 // a hidden input.
265 assert!(
266 resp.text.contains(r#"type="checkbox" name="item_type""#),
267 "type facet should be checkboxes (multi-select)"
268 );
269 assert!(
270 resp.text.contains(r#"type="radio" name="ai_tier""#),
271 "AI tier should be radios: its three tiers are nested ranges, not independent values"
272 );
273
274 // Two comboboxes on the page and a listbox each: the sidebar's tag
275 // typeahead and the search box beside the results, both described (N8).
276 // These assertions previously banned listbox roles outright, because the
277 // only one on the page was a fake.
278 assert_eq!(
279 resp.text.matches(r#"role="combobox""#).count(),
280 2,
281 "the tag typeahead and the search box both declare combobox"
282 );
283 // Derived from each field's name rather than authored, which is what the
284 // field owning its list buys: the box's id is the name and the list's is
285 // that plus a suffix, so the two cannot come apart. (N8.)
286 assert!(
287 resp.text
288 .contains(r#"aria-controls="tag-search-suggestions""#),
289 "the combobox must point at the listbox it controls"
290 );
291 assert!(
292 resp.text.contains(r#"aria-controls="q-suggestions""#),
293 "and so must the search box"
294 );
295 assert_eq!(
296 resp.text.matches(r#"role="listbox""#).count(),
297 2,
298 "one listbox per described box; filter lists must not claim the role"
299 );
300 assert!(
301 !resp.text.contains(r#"role="option""#),
302 "options are rendered by the typeahead at runtime, never server-side"
303 );
304 }
305
306 #[tokio::test]
307 async fn discover_filters_by_item_type() {
308 let mut h = TestHarness::new().await;
309
310 // Create a published item to have data
311 let user_id = h
312 .signup("discover1", "discover1@example.com", "password123")
313 .await;
314 h.grant_creator(user_id).await;
315 h.client.post_form("/logout", "").await;
316 h.login("discover1", "password123").await;
317
318 let resp = h
319 .client
320 .post_form("/api/projects", "slug=disc-proj&title=Disc+Project")
321 .await;
322 let project: Value = resp.json();
323 let project_id = project["id"].as_str().unwrap();
324
325 h.client
326 .post_form(
327 &format!("/api/projects/{project_id}/items"),
328 "title=Audio+Track&price_cents=0&item_type=audio",
329 )
330 .await;
331
332 // Make project and item public
333 h.client
334 .put_json(
335 &format!("/api/projects/{project_id}"),
336 r#"{"is_public": true}"#,
337 )
338 .await;
339
340 // Get the item IDs from the database to publish them
341 let items_list = sqlx::query_scalar::<_, Uuid>("SELECT id FROM items WHERE project_id = $1")
342 .bind(project_id.parse::<Uuid>().unwrap())
343 .fetch_all(&h.db)
344 .await
345 .unwrap();
346 for iid in &items_list {
347 h.client
348 .put_form(&format!("/api/items/{iid}"), "is_public=true")
349 .await;
350 }
351
352 // HTMX GET with item_type filter
353 let resp = h.client.htmx_get("/discover/results?item_type=audio").await;
354 assert_eq!(resp.status, 200, "Filtered discover should return 200");
355 // Should contain results HTML
356 assert!(
357 resp.text.contains("results-container") || resp.text.contains("results-table"),
358 "Filtered discover results should contain HTML structure"
359 );
360 }
361
362 #[tokio::test]
363 async fn discover_pagination() {
364 let mut h = TestHarness::new().await;
365
366 // Request page 2: even with no data, should return valid pagination HTML
367 let resp = h.client.htmx_get("/discover/results?page=2").await;
368 assert_eq!(resp.status, 200, "Discover page 2 should return 200");
369 // Should contain the pagination area and results structure
370 assert!(
371 resp.text.contains("results-container") || resp.text.contains("results-table"),
372 "Paginated discover results should contain HTML structure"
373 );
374 // Should contain the page info
375 assert!(
376 resp.text.contains("Showing"),
377 "Paginated results should contain 'Showing' text"
378 );
379 }
380
381 #[tokio::test]
382 async fn discover_huge_page_does_not_overflow() {
383 let mut h = TestHarness::new().await;
384
385 // `(page - 1) * DISCOVER_PAGE_SIZE` used to be computed in u32; a page this
386 // large overflows u32 (and, with overflow-checks on in the test profile,
387 // panicked into a 500). The offset/label math is now i64-widened and
388 // saturating, so a hostile page just yields an empty, well-formed page.
389 let resp = h.client.htmx_get("/discover/results?page=200000000").await;
390 assert_eq!(
391 resp.status, 200,
392 "huge ?page must not overflow into a 500: {}",
393 resp.text
394 );
395 }
396
397 // Inline Editing
398
399 #[tokio::test]
400 async fn edit_row_returns_form() {
401 let mut h = TestHarness::new().await;
402 let item_id = h
403 .create_creator_with_item("htmxuser", "audio", 500)
404 .await
405 .item_id;
406
407 let resp = h
408 .client
409 .htmx_get(&format!("/dashboard/item/{item_id}/edit-row"))
410 .await;
411 assert_eq!(resp.status, 200, "Edit row should return 200");
412 // Should contain form elements
413 assert!(
414 resp.text.contains("edit-row"),
415 "Edit row should contain edit-row class"
416 );
417 assert!(
418 resp.text.contains("name=\"title\""),
419 "Edit row should contain title input"
420 );
421 }
422
423 #[tokio::test]
424 async fn item_update_returns_json() {
425 let mut h = TestHarness::new().await;
426 let item_id = h
427 .create_creator_with_item("htmxuser", "audio", 500)
428 .await
429 .item_id;
430
431 // The update_item handler returns JSON for non-HTMX requests
432 let resp = h
433 .client
434 .put_form(&format!("/api/items/{item_id}"), "title=Updated+Title")
435 .await;
436 assert_eq!(
437 resp.status, 200,
438 "Item update should return 200, got {} {}",
439 resp.status, resp.text
440 );
441 let body: Value = resp.json();
442 assert_eq!(body["title"], "Updated Title");
443 }
444
445 #[tokio::test]
446 async fn item_update_nonexistent_returns_error() {
447 let mut h = TestHarness::new().await;
448 let _ = h.create_creator_with_item("htmxuser", "audio", 500).await;
449
450 // Try to update a non-existent item
451 let fake_id = Uuid::new_v4();
452 let resp = h
453 .client
454 .htmx_put_form(&format!("/api/items/{fake_id}"), "title=Nope")
455 .await;
456 assert_eq!(
457 resp.status, 404,
458 "Updating non-existent item should return 404, got {} {}",
459 resp.status, resp.text
460 );
461 }
462
463 // Tag Operations
464
465 #[tokio::test]
466 async fn add_tag_returns_html() {
467 let mut h = TestHarness::new().await;
468 let item_id = h
469 .create_creator_with_item("htmxuser", "audio", 500)
470 .await
471 .item_id;
472
473 // Insert a leaf tag (depth >= 3) directly in the database
474 let tag_id = Uuid::new_v4();
475 sqlx::query("INSERT INTO tags (id, name, slug, sort_order, path) VALUES ($1, $2, $3, 0, $4)")
476 .bind(tag_id)
477 .bind("TestTag")
478 .bind("audio.genre.testtag")
479 .bind("audio.genre.testtag")
480 .execute(&h.db)
481 .await
482 .expect("Failed to insert tag");
483
484 // HTMX POST to add the tag
485 let resp = h
486 .client
487 .htmx_post_form(
488 &format!("/api/items/{item_id}/tags"),
489 &format!("tag_id={tag_id}"),
490 )
491 .await;
492 assert_eq!(
493 resp.status, 200,
494 "Add tag should return 200, got {} {}",
495 resp.status, resp.text
496 );
497 // Should return rendered TagTemplate HTML
498 assert!(
499 resp.text.contains("tag"),
500 "Add tag response should contain tag markup"
501 );
502 assert!(
503 resp.text.contains("TestTag"),
504 "Add tag response should contain the tag name"
505 );
506 }
507
508 #[tokio::test]
509 async fn tag_suggestions_returns_html() {
510 let mut h = TestHarness::new().await;
511 let item_id = h
512 .create_creator_with_item("htmxuser", "audio", 500)
513 .await
514 .item_id;
515
516 // The tags table may already have seeded tags. Request suggestions for the
517 // item, the handler matches tags based on item title/description/type.
518 // It returns either an HTML fragment with suggestions or empty HTML.
519 let resp = h
520 .client
521 .htmx_get(&format!("/api/items/{item_id}/tag-suggestions"))
522 .await;
523 assert_eq!(
524 resp.status, 200,
525 "Tag suggestions should return 200, got {}",
526 resp.status
527 );
528 // Response is valid HTML (possibly empty if no tags match)
529 }
530
531 // Delete + Toast
532
533 #[tokio::test]
534 async fn delete_item_returns_toast() {
535 let mut h = TestHarness::new().await;
536 let item_id = h
537 .create_creator_with_item("htmxuser", "audio", 500)
538 .await
539 .item_id;
540
541 let resp = h.client.htmx_delete(&format!("/api/items/{item_id}")).await;
542 assert_eq!(
543 resp.status, 200,
544 "Delete item should succeed, got {} {}",
545 resp.status, resp.text
546 );
547 // delete_item always returns HX-Trigger with showToast (no HTMX check needed)
548 let trigger = resp
549 .header("HX-Trigger")
550 .expect("Should have HX-Trigger header");
551 assert!(
552 trigger.contains("showToast"),
553 "HX-Trigger should contain showToast, got: {trigger}"
554 );
555 assert!(
556 trigger.contains("success"),
557 "Toast should be success type, got: {trigger}"
558 );
559 }
560
561 #[tokio::test]
562 async fn delete_link_returns_toast() {
563 let mut h = TestHarness::new().await;
564 let _user_id = h
565 .signup("linkdel", "linkdel@example.com", "password123")
566 .await;
567
568 // Create a link first via HTMX POST
569 let resp = h
570 .client
571 .htmx_post_form("/api/links", "url=https%3A%2F%2Fexample.com&title=My+Link")
572 .await;
573 assert_eq!(
574 resp.status, 200,
575 "Create link should succeed, got {} {}",
576 resp.status, resp.text
577 );
578 // The HTMX response is HTML (link_row), extract the link ID from data-id attribute
579 let link_id = resp
580 .text
581 .split("data-id=\"")
582 .nth(1)
583 .and_then(|s| s.split('"').next())
584 .expect("Link row should have data-id attribute");
585
586 let resp = h.client.htmx_delete(&format!("/api/links/{link_id}")).await;
587 assert_eq!(
588 resp.status, 200,
589 "Delete link should succeed, got {} {}",
590 resp.status, resp.text
591 );
592 let trigger = resp
593 .header("HX-Trigger")
594 .expect("Should have HX-Trigger header");
595 assert!(
596 trigger.contains("showToast"),
597 "HX-Trigger should contain showToast, got: {trigger}"
598 );
599 assert!(
600 trigger.contains("Link removed"),
601 "Toast message should say 'Link removed', got: {trigger}"
602 );
603 }
604
605 // Form Loading
606
607 #[tokio::test]
608 async fn old_modal_form_routes_return_404() {
609 let mut h = TestHarness::new().await;
610 let _user_id = h.create_creator("formuser").await;
611
612 // Old modal form routes removed in favour of creation wizards
613 let resp = h.client.htmx_get("/dashboard/new-project-form").await;
614 assert_eq!(resp.status, 404, "Old project form route should be gone");
615
616 let resp = h
617 .client
618 .htmx_get("/dashboard/project/anything/new-item-form")
619 .await;
620 assert_eq!(resp.status, 404, "Old item form route should be gone");
621 }
622