Skip to main content

max / makenotwork

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