Skip to main content

max / makenotwork

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