Skip to main content

max / makenotwork

18.9 KB · 606 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-Trigger", "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 assert!(
276 resp.text.contains(r#"aria-controls="tag-suggest-list""#),
277 "the combobox must point at the listbox it controls"
278 );
279 assert_eq!(
280 resp.text.matches(r#"role="listbox""#).count(),
281 1,
282 "the typeahead should be the only listbox; filter lists must not claim the role"
283 );
284 assert!(
285 !resp.text.contains(r#"role="option""#),
286 "options are rendered by the typeahead at runtime, never server-side"
287 );
288 }
289
290 #[tokio::test]
291 async fn discover_filters_by_item_type() {
292 let mut h = TestHarness::new().await;
293
294 // Create a published item to have data
295 let user_id = h
296 .signup("discover1", "discover1@example.com", "password123")
297 .await;
298 h.grant_creator(user_id).await;
299 h.client.post_form("/logout", "").await;
300 h.login("discover1", "password123").await;
301
302 let resp = h
303 .client
304 .post_form("/api/projects", "slug=disc-proj&title=Disc+Project")
305 .await;
306 let project: Value = resp.json();
307 let project_id = project["id"].as_str().unwrap();
308
309 h.client
310 .post_form(
311 &format!("/api/projects/{project_id}/items"),
312 "title=Audio+Track&price_cents=0&item_type=audio",
313 )
314 .await;
315
316 // Make project and item public
317 h.client
318 .put_json(
319 &format!("/api/projects/{project_id}"),
320 r#"{"is_public": true}"#,
321 )
322 .await;
323
324 // Get the item IDs from the database to publish them
325 let items_list = sqlx::query_scalar::<_, Uuid>("SELECT id FROM items WHERE project_id = $1")
326 .bind(project_id.parse::<Uuid>().unwrap())
327 .fetch_all(&h.db)
328 .await
329 .unwrap();
330 for iid in &items_list {
331 h.client
332 .put_form(&format!("/api/items/{iid}"), "is_public=true")
333 .await;
334 }
335
336 // HTMX GET with item_type filter
337 let resp = h.client.htmx_get("/discover/results?item_type=audio").await;
338 assert_eq!(resp.status, 200, "Filtered discover should return 200");
339 // Should contain results HTML
340 assert!(
341 resp.text.contains("results-container") || resp.text.contains("results-table"),
342 "Filtered discover results should contain HTML structure"
343 );
344 }
345
346 #[tokio::test]
347 async fn discover_pagination() {
348 let mut h = TestHarness::new().await;
349
350 // Request page 2: even with no data, should return valid pagination HTML
351 let resp = h.client.htmx_get("/discover/results?page=2").await;
352 assert_eq!(resp.status, 200, "Discover page 2 should return 200");
353 // Should contain the pagination area and results structure
354 assert!(
355 resp.text.contains("results-container") || resp.text.contains("results-table"),
356 "Paginated discover results should contain HTML structure"
357 );
358 // Should contain the page info
359 assert!(
360 resp.text.contains("Showing"),
361 "Paginated results should contain 'Showing' text"
362 );
363 }
364
365 #[tokio::test]
366 async fn discover_huge_page_does_not_overflow() {
367 let mut h = TestHarness::new().await;
368
369 // `(page - 1) * DISCOVER_PAGE_SIZE` used to be computed in u32; a page this
370 // large overflows u32 (and, with overflow-checks on in the test profile,
371 // panicked into a 500). The offset/label math is now i64-widened and
372 // saturating, so a hostile page just yields an empty, well-formed page.
373 let resp = h.client.htmx_get("/discover/results?page=200000000").await;
374 assert_eq!(
375 resp.status, 200,
376 "huge ?page must not overflow into a 500: {}",
377 resp.text
378 );
379 }
380
381 // Inline Editing
382
383 #[tokio::test]
384 async fn edit_row_returns_form() {
385 let mut h = TestHarness::new().await;
386 let item_id = h
387 .create_creator_with_item("htmxuser", "audio", 500)
388 .await
389 .item_id;
390
391 let resp = h
392 .client
393 .htmx_get(&format!("/dashboard/item/{item_id}/edit-row"))
394 .await;
395 assert_eq!(resp.status, 200, "Edit row should return 200");
396 // Should contain form elements
397 assert!(
398 resp.text.contains("edit-row"),
399 "Edit row should contain edit-row class"
400 );
401 assert!(
402 resp.text.contains("name=\"title\""),
403 "Edit row should contain title input"
404 );
405 }
406
407 #[tokio::test]
408 async fn item_update_returns_json() {
409 let mut h = TestHarness::new().await;
410 let item_id = h
411 .create_creator_with_item("htmxuser", "audio", 500)
412 .await
413 .item_id;
414
415 // The update_item handler returns JSON for non-HTMX requests
416 let resp = h
417 .client
418 .put_form(&format!("/api/items/{item_id}"), "title=Updated+Title")
419 .await;
420 assert_eq!(
421 resp.status, 200,
422 "Item update should return 200, got {} {}",
423 resp.status, resp.text
424 );
425 let body: Value = resp.json();
426 assert_eq!(body["title"], "Updated Title");
427 }
428
429 #[tokio::test]
430 async fn item_update_nonexistent_returns_error() {
431 let mut h = TestHarness::new().await;
432 let _ = h.create_creator_with_item("htmxuser", "audio", 500).await;
433
434 // Try to update a non-existent item
435 let fake_id = Uuid::new_v4();
436 let resp = h
437 .client
438 .htmx_put_form(&format!("/api/items/{fake_id}"), "title=Nope")
439 .await;
440 assert_eq!(
441 resp.status, 404,
442 "Updating non-existent item should return 404, got {} {}",
443 resp.status, resp.text
444 );
445 }
446
447 // Tag Operations
448
449 #[tokio::test]
450 async fn add_tag_returns_html() {
451 let mut h = TestHarness::new().await;
452 let item_id = h
453 .create_creator_with_item("htmxuser", "audio", 500)
454 .await
455 .item_id;
456
457 // Insert a leaf tag (depth >= 3) directly in the database
458 let tag_id = Uuid::new_v4();
459 sqlx::query("INSERT INTO tags (id, name, slug, sort_order, path) VALUES ($1, $2, $3, 0, $4)")
460 .bind(tag_id)
461 .bind("TestTag")
462 .bind("audio.genre.testtag")
463 .bind("audio.genre.testtag")
464 .execute(&h.db)
465 .await
466 .expect("Failed to insert tag");
467
468 // HTMX POST to add the tag
469 let resp = h
470 .client
471 .htmx_post_form(
472 &format!("/api/items/{item_id}/tags"),
473 &format!("tag_id={tag_id}"),
474 )
475 .await;
476 assert_eq!(
477 resp.status, 200,
478 "Add tag should return 200, got {} {}",
479 resp.status, resp.text
480 );
481 // Should return rendered TagTemplate HTML
482 assert!(
483 resp.text.contains("tag"),
484 "Add tag response should contain tag markup"
485 );
486 assert!(
487 resp.text.contains("TestTag"),
488 "Add tag response should contain the tag name"
489 );
490 }
491
492 #[tokio::test]
493 async fn tag_suggestions_returns_html() {
494 let mut h = TestHarness::new().await;
495 let item_id = h
496 .create_creator_with_item("htmxuser", "audio", 500)
497 .await
498 .item_id;
499
500 // The tags table may already have seeded tags. Request suggestions for the
501 // item, the handler matches tags based on item title/description/type.
502 // It returns either an HTML fragment with suggestions or empty HTML.
503 let resp = h
504 .client
505 .htmx_get(&format!("/api/items/{item_id}/tag-suggestions"))
506 .await;
507 assert_eq!(
508 resp.status, 200,
509 "Tag suggestions should return 200, got {}",
510 resp.status
511 );
512 // Response is valid HTML (possibly empty if no tags match)
513 }
514
515 // Delete + Toast
516
517 #[tokio::test]
518 async fn delete_item_returns_toast() {
519 let mut h = TestHarness::new().await;
520 let item_id = h
521 .create_creator_with_item("htmxuser", "audio", 500)
522 .await
523 .item_id;
524
525 let resp = h.client.htmx_delete(&format!("/api/items/{item_id}")).await;
526 assert_eq!(
527 resp.status, 200,
528 "Delete item should succeed, got {} {}",
529 resp.status, resp.text
530 );
531 // delete_item always returns HX-Trigger with showToast (no HTMX check needed)
532 let trigger = resp
533 .header("HX-Trigger")
534 .expect("Should have HX-Trigger header");
535 assert!(
536 trigger.contains("showToast"),
537 "HX-Trigger should contain showToast, got: {trigger}"
538 );
539 assert!(
540 trigger.contains("success"),
541 "Toast should be success type, got: {trigger}"
542 );
543 }
544
545 #[tokio::test]
546 async fn delete_link_returns_toast() {
547 let mut h = TestHarness::new().await;
548 let _user_id = h
549 .signup("linkdel", "linkdel@example.com", "password123")
550 .await;
551
552 // Create a link first via HTMX POST
553 let resp = h
554 .client
555 .htmx_post_form("/api/links", "url=https%3A%2F%2Fexample.com&title=My+Link")
556 .await;
557 assert_eq!(
558 resp.status, 200,
559 "Create link should succeed, got {} {}",
560 resp.status, resp.text
561 );
562 // The HTMX response is HTML (link_row), extract the link ID from data-id attribute
563 let link_id = resp
564 .text
565 .split("data-id=\"")
566 .nth(1)
567 .and_then(|s| s.split('"').next())
568 .expect("Link row should have data-id attribute");
569
570 let resp = h.client.htmx_delete(&format!("/api/links/{link_id}")).await;
571 assert_eq!(
572 resp.status, 200,
573 "Delete link should succeed, got {} {}",
574 resp.status, resp.text
575 );
576 let trigger = resp
577 .header("HX-Trigger")
578 .expect("Should have HX-Trigger header");
579 assert!(
580 trigger.contains("showToast"),
581 "HX-Trigger should contain showToast, got: {trigger}"
582 );
583 assert!(
584 trigger.contains("Link removed"),
585 "Toast message should say 'Link removed', got: {trigger}"
586 );
587 }
588
589 // Form Loading
590
591 #[tokio::test]
592 async fn old_modal_form_routes_return_404() {
593 let mut h = TestHarness::new().await;
594 let _user_id = h.create_creator("formuser").await;
595
596 // Old modal form routes removed in favour of creation wizards
597 let resp = h.client.htmx_get("/dashboard/new-project-form").await;
598 assert_eq!(resp.status, 404, "Old project form route should be gone");
599
600 let resp = h
601 .client
602 .htmx_get("/dashboard/project/anything/new-item-form")
603 .await;
604 assert_eq!(resp.status, 404, "Old item form route should be gone");
605 }
606