//! HTMX integration tests: verify that HTMX-aware routes return the expected //! HTML fragments, headers, and status codes. use crate::harness::TestHarness; use serde_json::Value; use uuid::Uuid; // Dashboard Tabs #[tokio::test] async fn dashboard_tabs_return_html_fragments() { let mut h = TestHarness::new().await; let _user_id = h.signup("tabuser", "tab@example.com", "password123").await; let tabs = ["details", "payments", "projects", "creator"]; for tab in tabs { let resp = h.client.htmx_get(&format!("/dashboard/tabs/{tab}")).await; assert_eq!( resp.status, 200, "Dashboard tab '{}' should return 200, got {}", tab, resp.status ); // Each tab returns an HTML fragment (not a full page with ) assert!( !resp.text.contains("("SELECT id FROM items WHERE project_id = $1") .bind(project_id.parse::().unwrap()) .fetch_all(&h.db) .await .unwrap(); for iid in &items_list { h.client .put_form(&format!("/api/items/{iid}"), "is_public=true") .await; } // HTMX GET with item_type filter let resp = h.client.htmx_get("/discover/results?item_type=audio").await; assert_eq!(resp.status, 200, "Filtered discover should return 200"); // Should contain results HTML assert!( resp.text.contains("results-container") || resp.text.contains("results-table"), "Filtered discover results should contain HTML structure" ); } #[tokio::test] async fn discover_pagination() { let mut h = TestHarness::new().await; // Request page 2: even with no data, should return valid pagination HTML let resp = h.client.htmx_get("/discover/results?page=2").await; assert_eq!(resp.status, 200, "Discover page 2 should return 200"); // Should contain the pagination area and results structure assert!( resp.text.contains("results-container") || resp.text.contains("results-table"), "Paginated discover results should contain HTML structure" ); // Should contain the page info assert!( resp.text.contains("Showing"), "Paginated results should contain 'Showing' text" ); } #[tokio::test] async fn discover_huge_page_does_not_overflow() { let mut h = TestHarness::new().await; // `(page - 1) * DISCOVER_PAGE_SIZE` used to be computed in u32; a page this // large overflows u32 (and, with overflow-checks on in the test profile, // panicked into a 500). The offset/label math is now i64-widened and // saturating, so a hostile page just yields an empty, well-formed page. let resp = h.client.htmx_get("/discover/results?page=200000000").await; assert_eq!( resp.status, 200, "huge ?page must not overflow into a 500: {}", resp.text ); } // Inline Editing #[tokio::test] async fn edit_row_returns_form() { let mut h = TestHarness::new().await; let item_id = h .create_creator_with_item("htmxuser", "audio", 500) .await .item_id; let resp = h .client .htmx_get(&format!("/dashboard/item/{item_id}/edit-row")) .await; assert_eq!(resp.status, 200, "Edit row should return 200"); // Should contain form elements assert!( resp.text.contains("edit-row"), "Edit row should contain edit-row class" ); assert!( resp.text.contains("name=\"title\""), "Edit row should contain title input" ); } #[tokio::test] async fn item_update_returns_json() { let mut h = TestHarness::new().await; let item_id = h .create_creator_with_item("htmxuser", "audio", 500) .await .item_id; // The update_item handler returns JSON for non-HTMX requests let resp = h .client .put_form(&format!("/api/items/{item_id}"), "title=Updated+Title") .await; assert_eq!( resp.status, 200, "Item update should return 200, got {} {}", resp.status, resp.text ); let body: Value = resp.json(); assert_eq!(body["title"], "Updated Title"); } #[tokio::test] async fn item_update_nonexistent_returns_error() { let mut h = TestHarness::new().await; let _ = h.create_creator_with_item("htmxuser", "audio", 500).await; // Try to update a non-existent item let fake_id = Uuid::new_v4(); let resp = h .client .htmx_put_form(&format!("/api/items/{fake_id}"), "title=Nope") .await; assert_eq!( resp.status, 404, "Updating non-existent item should return 404, got {} {}", resp.status, resp.text ); } // Tag Operations #[tokio::test] async fn add_tag_returns_html() { let mut h = TestHarness::new().await; let item_id = h .create_creator_with_item("htmxuser", "audio", 500) .await .item_id; // Insert a leaf tag (depth >= 3) directly in the database let tag_id = Uuid::new_v4(); sqlx::query("INSERT INTO tags (id, name, slug, sort_order, path) VALUES ($1, $2, $3, 0, $4)") .bind(tag_id) .bind("TestTag") .bind("audio.genre.testtag") .bind("audio.genre.testtag") .execute(&h.db) .await .expect("Failed to insert tag"); // HTMX POST to add the tag let resp = h .client .htmx_post_form( &format!("/api/items/{item_id}/tags"), &format!("tag_id={tag_id}"), ) .await; assert_eq!( resp.status, 200, "Add tag should return 200, got {} {}", resp.status, resp.text ); // Should return rendered TagTemplate HTML assert!( resp.text.contains("tag"), "Add tag response should contain tag markup" ); assert!( resp.text.contains("TestTag"), "Add tag response should contain the tag name" ); } #[tokio::test] async fn tag_suggestions_returns_html() { let mut h = TestHarness::new().await; let item_id = h .create_creator_with_item("htmxuser", "audio", 500) .await .item_id; // The tags table may already have seeded tags. Request suggestions for the // item, the handler matches tags based on item title/description/type. // It returns either an HTML fragment with suggestions or empty HTML. let resp = h .client .htmx_get(&format!("/api/items/{item_id}/tag-suggestions")) .await; assert_eq!( resp.status, 200, "Tag suggestions should return 200, got {}", resp.status ); // Response is valid HTML (possibly empty if no tags match) } // Delete + Toast #[tokio::test] async fn delete_item_returns_toast() { let mut h = TestHarness::new().await; let item_id = h .create_creator_with_item("htmxuser", "audio", 500) .await .item_id; let resp = h.client.htmx_delete(&format!("/api/items/{item_id}")).await; assert_eq!( resp.status, 200, "Delete item should succeed, got {} {}", resp.status, resp.text ); // delete_item always returns HX-Trigger with showToast (no HTMX check needed) let trigger = resp .header("HX-Trigger") .expect("Should have HX-Trigger header"); assert!( trigger.contains("showToast"), "HX-Trigger should contain showToast, got: {trigger}" ); assert!( trigger.contains("success"), "Toast should be success type, got: {trigger}" ); } #[tokio::test] async fn delete_link_returns_toast() { let mut h = TestHarness::new().await; let _user_id = h .signup("linkdel", "linkdel@example.com", "password123") .await; // Create a link first via HTMX POST let resp = h .client .htmx_post_form("/api/links", "url=https%3A%2F%2Fexample.com&title=My+Link") .await; assert_eq!( resp.status, 200, "Create link should succeed, got {} {}", resp.status, resp.text ); // The HTMX response is HTML (link_row), extract the link ID from data-id attribute let link_id = resp .text .split("data-id=\"") .nth(1) .and_then(|s| s.split('"').next()) .expect("Link row should have data-id attribute"); let resp = h.client.htmx_delete(&format!("/api/links/{link_id}")).await; assert_eq!( resp.status, 200, "Delete link should succeed, got {} {}", resp.status, resp.text ); let trigger = resp .header("HX-Trigger") .expect("Should have HX-Trigger header"); assert!( trigger.contains("showToast"), "HX-Trigger should contain showToast, got: {trigger}" ); assert!( trigger.contains("Link removed"), "Toast message should say 'Link removed', got: {trigger}" ); } // Form Loading #[tokio::test] async fn old_modal_form_routes_return_404() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("formuser").await; // Old modal form routes removed in favour of creation wizards let resp = h.client.htmx_get("/dashboard/new-project-form").await; assert_eq!(resp.status, 404, "Old project form route should be gone"); let resp = h .client .htmx_get("/dashboard/project/anything/new-item-form") .await; assert_eq!(resp.status, 404, "Old item form route should be gone"); }