Skip to main content

max / makenotwork

13.5 KB · 428 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 // =============================================================================
9 // Dashboard Tabs
10 // =============================================================================
11
12 #[tokio::test]
13 async fn dashboard_tabs_return_html_fragments() {
14 let mut h = TestHarness::new().await;
15 let _user_id = h.signup("tabuser", "tab@example.com", "password123").await;
16
17 let tabs = ["details", "payments", "projects", "creator"];
18 for tab in tabs {
19 let resp = h
20 .client
21 .htmx_get(&format!("/dashboard/tabs/{}", tab))
22 .await;
23 assert_eq!(
24 resp.status, 200,
25 "Dashboard tab '{}' should return 200, got {}",
26 tab, resp.status
27 );
28 // Each tab returns an HTML fragment (not a full page with <html>)
29 assert!(
30 !resp.text.contains("<!DOCTYPE"),
31 "Tab '{}' should return a fragment, not a full page",
32 tab
33 );
34 // Should contain some HTML content
35 assert!(
36 resp.text.contains('<'),
37 "Tab '{}' should contain HTML markup",
38 tab
39 );
40 }
41 }
42
43 #[tokio::test]
44 async fn project_tabs_return_html_fragments() {
45 let mut h = TestHarness::new().await;
46 let setup = h.create_creator_with_item("htmxuser", "audio", 500).await;
47 let slug = setup.slug;
48
49 let tabs = ["overview", "content", "analytics", "settings", "blog", "subscriptions"];
50 for tab in tabs {
51 let resp = h
52 .client
53 .htmx_get(&format!("/dashboard/project/{}/tabs/{}", slug, 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 '{}' should return a fragment, not a full page",
63 tab
64 );
65 assert!(
66 resp.text.contains('<'),
67 "Project tab '{}' should contain HTML markup",
68 tab
69 );
70 }
71 }
72
73 #[tokio::test]
74 async fn dashboard_tab_without_htmx_returns_full_page_or_error() {
75 let mut h = TestHarness::new().await;
76 let _user_id = h.signup("nohtmx", "nohtmx@example.com", "password123").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 // =============================================================================
107 // Discover
108 // =============================================================================
109
110 #[tokio::test]
111 async fn discover_results_returns_html() {
112 let mut h = TestHarness::new().await;
113
114 let resp = h.client.htmx_get("/discover/results").await;
115 assert_eq!(resp.status, 200, "Discover results should return 200");
116 // The partial includes results-container markup
117 assert!(
118 resp.text.contains("results-container") || resp.text.contains("results-table"),
119 "Discover results should contain results HTML"
120 );
121 }
122
123 #[tokio::test]
124 async fn discover_filters_by_item_type() {
125 let mut h = TestHarness::new().await;
126
127 // Create a published item to have data
128 let user_id = h.signup("discover1", "discover1@example.com", "password123").await;
129 h.grant_creator(user_id).await;
130 h.client.post_form("/logout", "").await;
131 h.login("discover1", "password123").await;
132
133 let resp = h
134 .client
135 .post_form("/api/projects", "slug=disc-proj&title=Disc+Project")
136 .await;
137 let project: Value = resp.json();
138 let project_id = project["id"].as_str().unwrap();
139
140 h.client
141 .post_form(
142 &format!("/api/projects/{}/items", project_id),
143 "title=Audio+Track&price_cents=0&item_type=audio",
144 )
145 .await;
146
147 // Make project and item public
148 h.client
149 .put_json(
150 &format!("/api/projects/{}", project_id),
151 r#"{"is_public": true}"#,
152 )
153 .await;
154
155 // Get the item IDs from the database to publish them
156 let items_list = sqlx::query_scalar::<_, Uuid>("SELECT id FROM items WHERE project_id = $1")
157 .bind(project_id.parse::<Uuid>().unwrap())
158 .fetch_all(&h.db)
159 .await
160 .unwrap();
161 for iid in &items_list {
162 h.client
163 .put_form(&format!("/api/items/{}", iid), "is_public=true")
164 .await;
165 }
166
167 // HTMX GET with item_type filter
168 let resp = h.client.htmx_get("/discover/results?item_type=audio").await;
169 assert_eq!(resp.status, 200, "Filtered discover should return 200");
170 // Should contain results HTML
171 assert!(
172 resp.text.contains("results-container") || resp.text.contains("results-table"),
173 "Filtered discover results should contain HTML structure"
174 );
175 }
176
177 #[tokio::test]
178 async fn discover_pagination() {
179 let mut h = TestHarness::new().await;
180
181 // Request page 2 -- even with no data, should return valid pagination HTML
182 let resp = h.client.htmx_get("/discover/results?page=2").await;
183 assert_eq!(resp.status, 200, "Discover page 2 should return 200");
184 // Should contain the pagination area and results structure
185 assert!(
186 resp.text.contains("results-container") || resp.text.contains("results-table"),
187 "Paginated discover results should contain HTML structure"
188 );
189 // Should contain the page info
190 assert!(
191 resp.text.contains("Showing"),
192 "Paginated results should contain 'Showing' text"
193 );
194 }
195
196 // =============================================================================
197 // Inline Editing
198 // =============================================================================
199
200 #[tokio::test]
201 async fn edit_row_returns_form() {
202 let mut h = TestHarness::new().await;
203 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
204
205 let resp = h
206 .client
207 .htmx_get(&format!("/dashboard/item/{}/edit-row", item_id))
208 .await;
209 assert_eq!(resp.status, 200, "Edit row should return 200");
210 // Should contain form elements
211 assert!(
212 resp.text.contains("edit-row"),
213 "Edit row should contain edit-row class"
214 );
215 assert!(
216 resp.text.contains("name=\"title\""),
217 "Edit row should contain title input"
218 );
219 }
220
221 #[tokio::test]
222 async fn item_update_returns_json() {
223 let mut h = TestHarness::new().await;
224 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
225
226 // The update_item handler returns JSON for non-HTMX requests
227 let resp = h
228 .client
229 .put_form(
230 &format!("/api/items/{}", item_id),
231 "title=Updated+Title",
232 )
233 .await;
234 assert_eq!(
235 resp.status, 200,
236 "Item update should return 200, got {} {}",
237 resp.status, resp.text
238 );
239 let body: Value = resp.json();
240 assert_eq!(body["title"], "Updated Title");
241 }
242
243 #[tokio::test]
244 async fn item_update_nonexistent_returns_error() {
245 let mut h = TestHarness::new().await;
246 let _ = h.create_creator_with_item("htmxuser", "audio", 500).await;
247
248 // Try to update a non-existent item
249 let fake_id = Uuid::new_v4();
250 let resp = h
251 .client
252 .htmx_put_form(
253 &format!("/api/items/{}", fake_id),
254 "title=Nope",
255 )
256 .await;
257 assert_eq!(
258 resp.status, 404,
259 "Updating non-existent item should return 404, got {} {}",
260 resp.status,
261 resp.text
262 );
263 }
264
265 // =============================================================================
266 // Tag Operations
267 // =============================================================================
268
269 #[tokio::test]
270 async fn add_tag_returns_html() {
271 let mut h = TestHarness::new().await;
272 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
273
274 // Insert a leaf tag (depth >= 3) directly in the database
275 let tag_id = Uuid::new_v4();
276 sqlx::query("INSERT INTO tags (id, name, slug, sort_order, path) VALUES ($1, $2, $3, 0, $4)")
277 .bind(tag_id)
278 .bind("TestTag")
279 .bind("audio.genre.testtag")
280 .bind("audio.genre.testtag")
281 .execute(&h.db)
282 .await
283 .expect("Failed to insert tag");
284
285 // HTMX POST to add the tag
286 let resp = h
287 .client
288 .htmx_post_form(
289 &format!("/api/items/{}/tags", item_id),
290 &format!("tag_id={}", tag_id),
291 )
292 .await;
293 assert_eq!(resp.status, 200, "Add tag should return 200, got {} {}", resp.status, resp.text);
294 // Should return rendered TagTemplate HTML
295 assert!(
296 resp.text.contains("tag"),
297 "Add tag response should contain tag markup"
298 );
299 assert!(
300 resp.text.contains("TestTag"),
301 "Add tag response should contain the tag name"
302 );
303 }
304
305 #[tokio::test]
306 async fn tag_suggestions_returns_html() {
307 let mut h = TestHarness::new().await;
308 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
309
310 // The tags table may already have seeded tags. Request suggestions for the
311 // item -- the handler matches tags based on item title/description/type.
312 // It returns either an HTML fragment with suggestions or empty HTML.
313 let resp = h
314 .client
315 .htmx_get(&format!("/api/items/{}/tag-suggestions", item_id))
316 .await;
317 assert_eq!(
318 resp.status, 200,
319 "Tag suggestions should return 200, got {}",
320 resp.status
321 );
322 // Response is valid HTML (possibly empty if no tags match)
323 }
324
325 // =============================================================================
326 // Delete + Toast
327 // =============================================================================
328
329 #[tokio::test]
330 async fn delete_item_returns_toast() {
331 let mut h = TestHarness::new().await;
332 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
333
334 let resp = h
335 .client
336 .htmx_delete(&format!("/api/items/{}", item_id))
337 .await;
338 assert!(
339 resp.status.is_success(),
340 "Delete item should succeed, got {} {}",
341 resp.status,
342 resp.text
343 );
344 // delete_item always returns HX-Trigger with showToast (no HTMX check needed)
345 let trigger = resp.header("HX-Trigger").expect("Should have HX-Trigger header");
346 assert!(
347 trigger.contains("showToast"),
348 "HX-Trigger should contain showToast, got: {}",
349 trigger
350 );
351 assert!(
352 trigger.contains("success"),
353 "Toast should be success type, got: {}",
354 trigger
355 );
356 }
357
358 #[tokio::test]
359 async fn delete_link_returns_toast() {
360 let mut h = TestHarness::new().await;
361 let _user_id = h.signup("linkdel", "linkdel@example.com", "password123").await;
362
363 // Create a link first via HTMX POST
364 let resp = h
365 .client
366 .htmx_post_form(
367 "/api/links",
368 "url=https%3A%2F%2Fexample.com&title=My+Link",
369 )
370 .await;
371 assert!(
372 resp.status.is_success(),
373 "Create link should succeed, got {} {}",
374 resp.status,
375 resp.text
376 );
377 // The HTMX response is HTML (link_row), extract the link ID from data-id attribute
378 let link_id = resp
379 .text
380 .split("data-id=\"")
381 .nth(1)
382 .and_then(|s| s.split('"').next())
383 .expect("Link row should have data-id attribute");
384
385 // Delete with HTMX
386 let resp = h
387 .client
388 .htmx_delete(&format!("/api/links/{}", link_id))
389 .await;
390 assert!(
391 resp.status.is_success(),
392 "Delete link should succeed, got {} {}",
393 resp.status,
394 resp.text
395 );
396 let trigger = resp.header("HX-Trigger").expect("Should have HX-Trigger header");
397 assert!(
398 trigger.contains("showToast"),
399 "HX-Trigger should contain showToast, got: {}",
400 trigger
401 );
402 assert!(
403 trigger.contains("Link removed"),
404 "Toast message should say 'Link removed', got: {}",
405 trigger
406 );
407 }
408
409 // =============================================================================
410 // Form Loading
411 // =============================================================================
412
413 #[tokio::test]
414 async fn old_modal_form_routes_return_404() {
415 let mut h = TestHarness::new().await;
416 let _user_id = h.create_creator("formuser").await;
417
418 // Old modal form routes removed in favour of creation wizards
419 let resp = h.client.htmx_get("/dashboard/new-project-form").await;
420 assert_eq!(resp.status, 404, "Old project form route should be gone");
421
422 let resp = h
423 .client
424 .htmx_get("/dashboard/project/anything/new-item-form")
425 .await;
426 assert_eq!(resp.status, 404, "Old item form route should be gone");
427 }
428