Skip to main content

max / makenotwork

14.0 KB · 440 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 #[tokio::test]
197 async fn discover_huge_page_does_not_overflow() {
198 let mut h = TestHarness::new().await;
199
200 // `(page - 1) * DISCOVER_PAGE_SIZE` used to be computed in u32; a page this
201 // large overflows u32 (and, with overflow-checks on in the test profile,
202 // panicked into a 500). The offset/label math is now i64-widened and
203 // saturating, so a hostile page just yields an empty, well-formed page.
204 let resp = h.client.htmx_get("/discover/results?page=200000000").await;
205 assert_eq!(resp.status, 200, "huge ?page must not overflow into a 500: {}", resp.text);
206 }
207
208 // =============================================================================
209 // Inline Editing
210 // =============================================================================
211
212 #[tokio::test]
213 async fn edit_row_returns_form() {
214 let mut h = TestHarness::new().await;
215 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
216
217 let resp = h
218 .client
219 .htmx_get(&format!("/dashboard/item/{}/edit-row", item_id))
220 .await;
221 assert_eq!(resp.status, 200, "Edit row should return 200");
222 // Should contain form elements
223 assert!(
224 resp.text.contains("edit-row"),
225 "Edit row should contain edit-row class"
226 );
227 assert!(
228 resp.text.contains("name=\"title\""),
229 "Edit row should contain title input"
230 );
231 }
232
233 #[tokio::test]
234 async fn item_update_returns_json() {
235 let mut h = TestHarness::new().await;
236 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
237
238 // The update_item handler returns JSON for non-HTMX requests
239 let resp = h
240 .client
241 .put_form(
242 &format!("/api/items/{}", item_id),
243 "title=Updated+Title",
244 )
245 .await;
246 assert_eq!(
247 resp.status, 200,
248 "Item update should return 200, got {} {}",
249 resp.status, resp.text
250 );
251 let body: Value = resp.json();
252 assert_eq!(body["title"], "Updated Title");
253 }
254
255 #[tokio::test]
256 async fn item_update_nonexistent_returns_error() {
257 let mut h = TestHarness::new().await;
258 let _ = h.create_creator_with_item("htmxuser", "audio", 500).await;
259
260 // Try to update a non-existent item
261 let fake_id = Uuid::new_v4();
262 let resp = h
263 .client
264 .htmx_put_form(
265 &format!("/api/items/{}", fake_id),
266 "title=Nope",
267 )
268 .await;
269 assert_eq!(
270 resp.status, 404,
271 "Updating non-existent item should return 404, got {} {}",
272 resp.status,
273 resp.text
274 );
275 }
276
277 // =============================================================================
278 // Tag Operations
279 // =============================================================================
280
281 #[tokio::test]
282 async fn add_tag_returns_html() {
283 let mut h = TestHarness::new().await;
284 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
285
286 // Insert a leaf tag (depth >= 3) directly in the database
287 let tag_id = Uuid::new_v4();
288 sqlx::query("INSERT INTO tags (id, name, slug, sort_order, path) VALUES ($1, $2, $3, 0, $4)")
289 .bind(tag_id)
290 .bind("TestTag")
291 .bind("audio.genre.testtag")
292 .bind("audio.genre.testtag")
293 .execute(&h.db)
294 .await
295 .expect("Failed to insert tag");
296
297 // HTMX POST to add the tag
298 let resp = h
299 .client
300 .htmx_post_form(
301 &format!("/api/items/{}/tags", item_id),
302 &format!("tag_id={}", tag_id),
303 )
304 .await;
305 assert_eq!(resp.status, 200, "Add tag should return 200, got {} {}", resp.status, resp.text);
306 // Should return rendered TagTemplate HTML
307 assert!(
308 resp.text.contains("tag"),
309 "Add tag response should contain tag markup"
310 );
311 assert!(
312 resp.text.contains("TestTag"),
313 "Add tag response should contain the tag name"
314 );
315 }
316
317 #[tokio::test]
318 async fn tag_suggestions_returns_html() {
319 let mut h = TestHarness::new().await;
320 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
321
322 // The tags table may already have seeded tags. Request suggestions for the
323 // item -- the handler matches tags based on item title/description/type.
324 // It returns either an HTML fragment with suggestions or empty HTML.
325 let resp = h
326 .client
327 .htmx_get(&format!("/api/items/{}/tag-suggestions", item_id))
328 .await;
329 assert_eq!(
330 resp.status, 200,
331 "Tag suggestions should return 200, got {}",
332 resp.status
333 );
334 // Response is valid HTML (possibly empty if no tags match)
335 }
336
337 // =============================================================================
338 // Delete + Toast
339 // =============================================================================
340
341 #[tokio::test]
342 async fn delete_item_returns_toast() {
343 let mut h = TestHarness::new().await;
344 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
345
346 let resp = h
347 .client
348 .htmx_delete(&format!("/api/items/{}", item_id))
349 .await;
350 assert!(
351 resp.status.is_success(),
352 "Delete item should succeed, got {} {}",
353 resp.status,
354 resp.text
355 );
356 // delete_item always returns HX-Trigger with showToast (no HTMX check needed)
357 let trigger = resp.header("HX-Trigger").expect("Should have HX-Trigger header");
358 assert!(
359 trigger.contains("showToast"),
360 "HX-Trigger should contain showToast, got: {}",
361 trigger
362 );
363 assert!(
364 trigger.contains("success"),
365 "Toast should be success type, got: {}",
366 trigger
367 );
368 }
369
370 #[tokio::test]
371 async fn delete_link_returns_toast() {
372 let mut h = TestHarness::new().await;
373 let _user_id = h.signup("linkdel", "linkdel@example.com", "password123").await;
374
375 // Create a link first via HTMX POST
376 let resp = h
377 .client
378 .htmx_post_form(
379 "/api/links",
380 "url=https%3A%2F%2Fexample.com&title=My+Link",
381 )
382 .await;
383 assert!(
384 resp.status.is_success(),
385 "Create link should succeed, got {} {}",
386 resp.status,
387 resp.text
388 );
389 // The HTMX response is HTML (link_row), extract the link ID from data-id attribute
390 let link_id = resp
391 .text
392 .split("data-id=\"")
393 .nth(1)
394 .and_then(|s| s.split('"').next())
395 .expect("Link row should have data-id attribute");
396
397 // Delete with HTMX
398 let resp = h
399 .client
400 .htmx_delete(&format!("/api/links/{}", link_id))
401 .await;
402 assert!(
403 resp.status.is_success(),
404 "Delete link should succeed, got {} {}",
405 resp.status,
406 resp.text
407 );
408 let trigger = resp.header("HX-Trigger").expect("Should have HX-Trigger header");
409 assert!(
410 trigger.contains("showToast"),
411 "HX-Trigger should contain showToast, got: {}",
412 trigger
413 );
414 assert!(
415 trigger.contains("Link removed"),
416 "Toast message should say 'Link removed', got: {}",
417 trigger
418 );
419 }
420
421 // =============================================================================
422 // Form Loading
423 // =============================================================================
424
425 #[tokio::test]
426 async fn old_modal_form_routes_return_404() {
427 let mut h = TestHarness::new().await;
428 let _user_id = h.create_creator("formuser").await;
429
430 // Old modal form routes removed in favour of creation wizards
431 let resp = h.client.htmx_get("/dashboard/new-project-form").await;
432 assert_eq!(resp.status, 404, "Old project form route should be gone");
433
434 let resp = h
435 .client
436 .htmx_get("/dashboard/project/anything/new-item-form")
437 .await;
438 assert_eq!(resp.status, 404, "Old item form route should be gone");
439 }
440