Skip to main content

max / makenotwork

24.4 KB · 767 lines History Blame Raw
1 use crate::harness::TestHarness;
2
3 #[tokio::test]
4 async fn create_thread_happy_path() {
5 let mut h = TestHarness::new().await;
6 let user_id = h.login_as("threadcreator").await;
7 let comm_id = h.create_community("Test", "test").await;
8 let _cat_id = h.create_category(comm_id, "General", "general").await;
9 h.add_membership(user_id, comm_id, "member").await;
10
11 // GET new thread form to get CSRF
12 h.client.get("/p/test/general/new").await;
13
14 let resp = h
15 .client
16 .post_form("/p/test/general/new", "title=My+Thread&body=Hello+world")
17 .await;
18
19 // Should redirect to the new thread
20 assert!(
21 resp.status.is_redirection() || resp.status.is_success(),
22 "Expected redirect, got {}",
23 resp.status
24 );
25 }
26
27 #[tokio::test]
28 async fn create_thread_requires_login() {
29 let mut h = TestHarness::new().await;
30 let comm_id = h.create_community("Test", "test").await;
31 let _cat_id = h.create_category(comm_id, "General", "general").await;
32
33 // GET page to get CSRF token (unauthenticated)
34 h.client.get("/p/test/general/new").await;
35
36 let resp = h
37 .client
38 .post_form("/p/test/general/new", "title=Nope&body=Should+fail")
39 .await;
40
41 // Should redirect to login
42 assert!(
43 resp.status.is_redirection(),
44 "Expected redirect to login, got {}",
45 resp.status
46 );
47 }
48
49 #[tokio::test]
50 async fn create_reply_happy_path() {
51 let mut h = TestHarness::new().await;
52 let user_id = h.login_as("replier").await;
53 let comm_id = h.create_community("Test", "test").await;
54 let cat_id = h.create_category(comm_id, "General", "general").await;
55 h.add_membership(user_id, comm_id, "member").await;
56
57 let thread_id = h
58 .create_thread_with_post(cat_id, user_id, "Test Thread", "OP content")
59 .await;
60
61 // GET thread page for CSRF
62 let thread_url = format!("/p/test/general/{thread_id}");
63 h.client.get(&thread_url).await;
64
65 let reply_url = format!("/p/test/general/{thread_id}/reply");
66 let resp = h.client.post_form(&reply_url, "body=Great+thread!").await;
67
68 assert!(
69 resp.status.is_redirection() || resp.status.is_success(),
70 "Expected redirect, got {}",
71 resp.status
72 );
73 }
74
75 #[tokio::test]
76 async fn reply_to_locked_thread_rejected() {
77 let mut h = TestHarness::new().await;
78 let user_id = h.login_as("lockedout").await;
79 let comm_id = h.create_community("Test", "test").await;
80 let cat_id = h.create_category(comm_id, "General", "general").await;
81 h.add_membership(user_id, comm_id, "member").await;
82
83 let thread_id = h
84 .create_thread_with_post(cat_id, user_id, "Locked Thread", "OP")
85 .await;
86
87 // Lock the thread
88 mt_db::mutations::set_thread_locked(&h.db, thread_id, true)
89 .await
90 .unwrap();
91
92 let thread_url = format!("/p/test/general/{thread_id}");
93 h.client.get(&thread_url).await;
94
95 let reply_url = format!("/p/test/general/{thread_id}/reply");
96 let resp = h.client.post_form(&reply_url, "body=Nope").await;
97
98 assert_eq!(resp.status.as_u16(), 403);
99 }
100
101 // Immutable posts, old edit/delete routes return 404
102
103 #[tokio::test]
104 async fn user_cannot_edit_post() {
105 let mut h = TestHarness::new().await;
106 let user_id = h.login_as("editor").await;
107 let comm_id = h.create_community("Test", "test").await;
108 let cat_id = h.create_category(comm_id, "General", "general").await;
109 h.add_membership(user_id, comm_id, "member").await;
110
111 let thread_id = h
112 .create_thread_with_post(cat_id, user_id, "Edit Test", "Original body")
113 .await;
114
115 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
116 .await
117 .unwrap();
118 let post_id = posts[0].id;
119
120 // GET edit form should 404 (route removed)
121 let edit_url = format!("/p/test/general/{thread_id}/posts/{post_id}/edit");
122 let resp = h.client.get(&edit_url).await;
123 assert_eq!(resp.status.as_u16(), 404, "Edit GET should be 404");
124
125 // POST edit should also 404
126 let resp = h.client.post_form(&edit_url, "body=Updated+body").await;
127 assert_eq!(resp.status.as_u16(), 404, "Edit POST should be 404");
128 }
129
130 #[tokio::test]
131 async fn user_cannot_delete_post() {
132 let mut h = TestHarness::new().await;
133 let user_id = h.login_as("deleter").await;
134 let comm_id = h.create_community("Test", "test").await;
135 let cat_id = h.create_category(comm_id, "General", "general").await;
136 h.add_membership(user_id, comm_id, "member").await;
137
138 let thread_id = h
139 .create_thread_with_post(cat_id, user_id, "Delete Test", "Will not be deleted")
140 .await;
141
142 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
143 .await
144 .unwrap();
145 let post_id = posts[0].id;
146
147 let thread_url = format!("/p/test/general/{thread_id}");
148 h.client.get(&thread_url).await;
149
150 let delete_url = format!("/p/test/general/{thread_id}/posts/{post_id}/delete");
151 let resp = h.client.post_form(&delete_url, "").await;
152 assert_eq!(resp.status.as_u16(), 404, "Delete POST should be 404");
153 }
154
155 // Mod removal
156
157 #[tokio::test]
158 async fn mod_can_remove_post() {
159 let mut h = TestHarness::new().await;
160 let author_id = h.login_as("author").await;
161 let comm_id = h.create_community("Test", "test").await;
162 let cat_id = h.create_category(comm_id, "General", "general").await;
163 h.add_membership(author_id, comm_id, "member").await;
164
165 let thread_id = h
166 .create_thread_with_post(cat_id, author_id, "Mod Test", "Problematic content")
167 .await;
168
169 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
170 .await
171 .unwrap();
172 let post_id = posts[0].id;
173
174 let mod_id = h.login_as("moduser").await;
175 h.add_membership(mod_id, comm_id, "moderator").await;
176
177 let thread_url = format!("/p/test/general/{thread_id}");
178 h.client.get(&thread_url).await;
179
180 let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove");
181 let resp = h.client.post_form(&remove_url, "").await;
182 assert!(
183 resp.status.is_redirection(),
184 "Expected redirect, got {}",
185 resp.status
186 );
187
188 // Verify removed_at is set but content preserved. Direct loader use is fine
189 // in a test assertion (no community-scope invariant to uphold here).
190 #[allow(clippy::disallowed_methods)]
191 let post_data = mt_db::queries::get_post_for_edit(&h.db, post_id)
192 .await
193 .unwrap()
194 .unwrap()
195 .into_inner_unchecked();
196 assert_eq!(
197 post_data.body_markdown, "Problematic content",
198 "Content should be preserved"
199 );
200
201 // Verify removed_at via direct query
202 let removed_at: Option<chrono::DateTime<chrono::Utc>> =
203 sqlx::query_scalar("SELECT removed_at FROM posts WHERE id = $1")
204 .bind(post_id)
205 .fetch_one(&h.db)
206 .await
207 .unwrap();
208 assert!(removed_at.is_some(), "removed_at should be set");
209 }
210
211 // Thread edit/delete restricted to mod/owner
212
213 #[tokio::test]
214 async fn mod_can_edit_thread_title() {
215 let mut h = TestHarness::new().await;
216 let author_id = h.login_as("threadauthor").await;
217 let comm_id = h.create_community("Test", "test").await;
218 let cat_id = h.create_category(comm_id, "General", "general").await;
219 h.add_membership(author_id, comm_id, "member").await;
220
221 let thread_id = h
222 .create_thread_with_post(cat_id, author_id, "Old Title", "Body")
223 .await;
224
225 let mod_id = h.login_as("modeditor").await;
226 h.add_membership(mod_id, comm_id, "moderator").await;
227
228 let edit_url = format!("/p/test/general/{thread_id}/edit");
229 h.client.get(&edit_url).await;
230
231 let resp = h.client.post_form(&edit_url, "title=New+Title").await;
232
233 assert!(
234 resp.status.is_redirection() || resp.status.is_success(),
235 "Expected redirect, got {}",
236 resp.status
237 );
238 }
239
240 #[tokio::test]
241 async fn mod_can_delete_thread() {
242 let mut h = TestHarness::new().await;
243 let author_id = h.login_as("threadauthor2").await;
244 let comm_id = h.create_community("Test", "test").await;
245 let cat_id = h.create_category(comm_id, "General", "general").await;
246 h.add_membership(author_id, comm_id, "member").await;
247
248 let thread_id = h
249 .create_thread_with_post(cat_id, author_id, "To Delete", "Body")
250 .await;
251
252 let mod_id = h.login_as("moddeleter").await;
253 h.add_membership(mod_id, comm_id, "moderator").await;
254
255 let thread_url = format!("/p/test/general/{thread_id}");
256 h.client.get(&thread_url).await;
257
258 let delete_url = format!("/p/test/general/{thread_id}/delete");
259 let resp = h.client.post_form(&delete_url, "").await;
260
261 assert!(
262 resp.status.is_redirection(),
263 "Expected redirect, got {}",
264 resp.status
265 );
266 }
267
268 #[tokio::test]
269 async fn user_cannot_edit_thread_title() {
270 let mut h = TestHarness::new().await;
271 let user_id = h.login_as("regularuser").await;
272 let comm_id = h.create_community("Test", "test").await;
273 let cat_id = h.create_category(comm_id, "General", "general").await;
274 h.add_membership(user_id, comm_id, "member").await;
275
276 let thread_id = h
277 .create_thread_with_post(cat_id, user_id, "My Thread", "Body")
278 .await;
279
280 let edit_url = format!("/p/test/general/{thread_id}/edit");
281 h.client.get(&edit_url).await;
282
283 let resp = h.client.post_form(&edit_url, "title=Hacked+Title").await;
284
285 assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403");
286 }
287
288 #[tokio::test]
289 async fn user_cannot_delete_thread() {
290 let mut h = TestHarness::new().await;
291 let user_id = h.login_as("regularuser2").await;
292 let comm_id = h.create_community("Test", "test").await;
293 let cat_id = h.create_category(comm_id, "General", "general").await;
294 h.add_membership(user_id, comm_id, "member").await;
295
296 let thread_id = h
297 .create_thread_with_post(cat_id, user_id, "My Thread", "Body")
298 .await;
299
300 let thread_url = format!("/p/test/general/{thread_id}");
301 h.client.get(&thread_url).await;
302
303 let delete_url = format!("/p/test/general/{thread_id}/delete");
304 let resp = h.client.post_form(&delete_url, "").await;
305
306 assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403");
307 }
308
309 // Footnotes
310
311 #[tokio::test]
312 async fn add_footnote_by_author() {
313 let mut h = TestHarness::new().await;
314 let user_id = h.login_as("fnauthor").await;
315 let comm_id = h.create_community("Test", "test").await;
316 let cat_id = h.create_category(comm_id, "General", "general").await;
317 h.add_membership(user_id, comm_id, "member").await;
318
319 let thread_id = h
320 .create_thread_with_post(cat_id, user_id, "Footnote Test", "Original post")
321 .await;
322
323 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
324 .await
325 .unwrap();
326 let post_id = posts[0].id;
327
328 let thread_url = format!("/p/test/general/{thread_id}");
329 h.client.get(&thread_url).await;
330
331 let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote");
332 let resp = h
333 .client
334 .post_form(&footnote_url, "body=Correction:+I+meant+something+else")
335 .await;
336
337 assert!(
338 resp.status.is_redirection(),
339 "Expected redirect, got {}",
340 resp.status
341 );
342
343 // Verify footnote in DB
344 let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id])
345 .await
346 .unwrap();
347 assert_eq!(footnotes.len(), 1);
348 assert_eq!(footnotes[0].author_id, user_id);
349
350 // Verify footnote renders on page
351 let resp = h.client.get(&thread_url).await;
352 assert!(
353 resp.text.contains("Correction:"),
354 "Footnote should be visible on thread page"
355 );
356 }
357
358 #[tokio::test]
359 async fn add_footnote_non_author_rejected() {
360 let mut h = TestHarness::new().await;
361 let author_id = h.login_as("fnoriginal").await;
362 let comm_id = h.create_community("Test", "test").await;
363 let cat_id = h.create_category(comm_id, "General", "general").await;
364 h.add_membership(author_id, comm_id, "member").await;
365
366 let thread_id = h
367 .create_thread_with_post(cat_id, author_id, "No Footnote For You", "Original")
368 .await;
369
370 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
371 .await
372 .unwrap();
373 let post_id = posts[0].id;
374
375 // Log in as a different user
376 let other_id = h.login_as("fnother").await;
377 h.add_membership(other_id, comm_id, "member").await;
378
379 let thread_url = format!("/p/test/general/{thread_id}");
380 h.client.get(&thread_url).await;
381
382 let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote");
383 let resp = h
384 .client
385 .post_form(&footnote_url, "body=Uninvited+footnote")
386 .await;
387
388 assert_eq!(resp.status.as_u16(), 403, "Non-author should get 403");
389 }
390
391 #[tokio::test]
392 async fn multiple_footnotes_ordered() {
393 let mut h = TestHarness::new().await;
394 let user_id = h.login_as("fnmulti").await;
395 let comm_id = h.create_community("Test", "test").await;
396 let cat_id = h.create_category(comm_id, "General", "general").await;
397 h.add_membership(user_id, comm_id, "member").await;
398
399 let thread_id = h
400 .create_thread_with_post(cat_id, user_id, "Multi Footnote", "Post body")
401 .await;
402
403 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
404 .await
405 .unwrap();
406 let post_id = posts[0].id;
407
408 // Insert two footnotes
409 mt_db::mutations::insert_footnote(
410 &h.db,
411 post_id,
412 user_id,
413 "First correction",
414 "<p>First correction</p>",
415 )
416 .await
417 .unwrap();
418
419 mt_db::mutations::insert_footnote(
420 &h.db,
421 post_id,
422 user_id,
423 "Second correction",
424 "<p>Second correction</p>",
425 )
426 .await
427 .unwrap();
428
429 let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id])
430 .await
431 .unwrap();
432
433 assert_eq!(footnotes.len(), 2);
434 assert!(
435 footnotes[0].created_at <= footnotes[1].created_at,
436 "Footnotes should be chronologically ordered"
437 );
438 }
439
440 #[tokio::test]
441 async fn footnote_on_removed_post_rejected() {
442 let mut h = TestHarness::new().await;
443 let author_id = h.login_as("fnremoved").await;
444 let comm_id = h.create_community("Test", "test").await;
445 let cat_id = h.create_category(comm_id, "General", "general").await;
446 h.add_membership(author_id, comm_id, "member").await;
447
448 let thread_id = h
449 .create_thread_with_post(cat_id, author_id, "Removed Post", "Content")
450 .await;
451
452 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
453 .await
454 .unwrap();
455 let post_id = posts[0].id;
456
457 // Mod-remove the post (use author_id since they're the only user in the DB)
458 mt_db::mutations::mod_remove_post(&h.db, post_id, author_id)
459 .await
460 .unwrap();
461
462 let thread_url = format!("/p/test/general/{thread_id}");
463 h.client.get(&thread_url).await;
464
465 let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote");
466 let resp = h
467 .client
468 .post_form(&footnote_url, "body=After+removal")
469 .await;
470
471 assert_eq!(
472 resp.status.as_u16(),
473 403,
474 "Footnote on removed post should be 403"
475 );
476 }
477
478 // Verified quoting
479
480 fn compute_quote_hash(text: &str) -> String {
481 use sha2::{Digest, Sha256};
482 let mut hasher = Sha256::new();
483 hasher.update(text.as_bytes());
484 let hash = hasher.finalize();
485 hex::encode(&hash[..4])
486 }
487
488 #[tokio::test]
489 async fn valid_quote_accepted() {
490 let mut h = TestHarness::new().await;
491 let user_id = h.login_as("quoter").await;
492 let comm_id = h.create_community("Test", "test").await;
493 let cat_id = h.create_category(comm_id, "General", "general").await;
494 h.add_membership(user_id, comm_id, "member").await;
495
496 let thread_id = h
497 .create_thread_with_post(
498 cat_id,
499 user_id,
500 "Quote Test",
501 "This is the original post content",
502 )
503 .await;
504
505 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
506 .await
507 .unwrap();
508 let post_id = posts[0].id;
509
510 let quoted_text = "This is the original post content";
511 let hash = compute_quote_hash(quoted_text);
512
513 let body = format!("> {quoted_text}\n[quote:{post_id}:{hash}]\n\nMy reply here");
514 let encoded_body = urlencoding::encode(&body);
515
516 let thread_url = format!("/p/test/general/{thread_id}");
517 h.client.get(&thread_url).await;
518
519 let reply_url = format!("/p/test/general/{thread_id}/reply");
520 let resp = h
521 .client
522 .post_form(&reply_url, &format!("body={encoded_body}"))
523 .await;
524
525 assert!(
526 resp.status.is_redirection() || resp.status.is_success(),
527 "Valid quote should be accepted, got {}",
528 resp.status
529 );
530 }
531
532 /// Regression (audit_review.md N3, verify_quotes scope gap): a `[quote:UUID:HASH]`
533 /// marker must only verify against a post *in the same community*. Quoting a post
534 /// that lives in another community, even with the exact text and correct hash,
535 /// must be rejected, closing the cross-community matching-oracle.
536 #[tokio::test]
537 async fn cross_community_quote_rejected() {
538 let mut h = TestHarness::new().await;
539 let user_id = h.login_as("crosscommquoter").await;
540
541 // Community Beta holds the target post.
542 let comm_b = h.create_community("Beta", "beta").await;
543 let cat_b = h.create_category(comm_b, "General", "general").await;
544 h.add_membership(user_id, comm_b, "member").await;
545 let secret_text = "Secret content only in community beta";
546 let thread_b = h
547 .create_thread_with_post(cat_b, user_id, "Beta Thread", secret_text)
548 .await;
549 let post_b = mt_db::queries::list_posts_in_thread(&h.db, thread_b)
550 .await
551 .unwrap()[0]
552 .id;
553
554 // Community Alpha is where the reply is composed.
555 let comm_a = h.create_community("Alpha", "alpha").await;
556 let cat_a = h.create_category(comm_a, "General", "general").await;
557 h.add_membership(user_id, comm_a, "member").await;
558 let thread_a = h
559 .create_thread_with_post(cat_a, user_id, "Alpha Thread", "alpha op content")
560 .await;
561
562 // Quote Beta's post (exact text + correct hash) from inside Alpha.
563 let hash = compute_quote_hash(secret_text);
564 let body = format!("> {secret_text}\n[quote:{post_b}:{hash}]\n\nQuoting across communities");
565 let encoded_body = urlencoding::encode(&body);
566
567 let thread_url = format!("/p/alpha/general/{thread_a}");
568 h.client.get(&thread_url).await;
569 let reply_url = format!("/p/alpha/general/{thread_a}/reply");
570 let resp = h
571 .client
572 .post_form(&reply_url, &format!("body={encoded_body}"))
573 .await;
574
575 assert_eq!(
576 resp.status.as_u16(),
577 422,
578 "quoting a post from another community must be rejected (N3 scope seal), got {}",
579 resp.status
580 );
581 }
582
583 #[tokio::test]
584 async fn fabricated_quote_rejected() {
585 let mut h = TestHarness::new().await;
586 let user_id = h.login_as("fabricator").await;
587 let comm_id = h.create_community("Test", "test").await;
588 let cat_id = h.create_category(comm_id, "General", "general").await;
589 h.add_membership(user_id, comm_id, "member").await;
590
591 let thread_id = h
592 .create_thread_with_post(
593 cat_id,
594 user_id,
595 "Fabrication Test",
596 "Actual original content",
597 )
598 .await;
599
600 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
601 .await
602 .unwrap();
603 let post_id = posts[0].id;
604
605 // Wrong hash (correct text)
606 let body = format!("> Actual original content\n[quote:{post_id}:deadbeef]\n\nLying reply");
607 let encoded_body = urlencoding::encode(&body);
608
609 let thread_url = format!("/p/test/general/{thread_id}");
610 h.client.get(&thread_url).await;
611
612 let reply_url = format!("/p/test/general/{thread_id}/reply");
613 let resp = h
614 .client
615 .post_form(&reply_url, &format!("body={encoded_body}"))
616 .await;
617
618 assert_eq!(
619 resp.status.as_u16(),
620 422,
621 "Fabricated quote hash should be rejected"
622 );
623 }
624
625 #[tokio::test]
626 async fn altered_quote_rejected() {
627 let mut h = TestHarness::new().await;
628 let user_id = h.login_as("alterer").await;
629 let comm_id = h.create_community("Test", "test").await;
630 let cat_id = h.create_category(comm_id, "General", "general").await;
631 h.add_membership(user_id, comm_id, "member").await;
632
633 let thread_id = h
634 .create_thread_with_post(cat_id, user_id, "Alter Test", "The sky is blue")
635 .await;
636
637 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
638 .await
639 .unwrap();
640 let post_id = posts[0].id;
641
642 // Correct hash for "The sky is blue" but altered text
643 let original_hash = compute_quote_hash("The sky is blue");
644 let body = format!("> The sky is green\n[quote:{post_id}:{original_hash}]\n\nMisquoting");
645 let encoded_body = urlencoding::encode(&body);
646
647 let thread_url = format!("/p/test/general/{thread_id}");
648 h.client.get(&thread_url).await;
649
650 let reply_url = format!("/p/test/general/{thread_id}/reply");
651 let resp = h
652 .client
653 .post_form(&reply_url, &format!("body={encoded_body}"))
654 .await;
655
656 assert_eq!(
657 resp.status.as_u16(),
658 422,
659 "Altered quote should be rejected"
660 );
661 }
662
663 #[tokio::test]
664 async fn quote_renders_with_attribution() {
665 let mut h = TestHarness::new().await;
666 let user_id = h.login_as("quoterrender").await;
667 let comm_id = h.create_community("Test", "test").await;
668 let cat_id = h.create_category(comm_id, "General", "general").await;
669 h.add_membership(user_id, comm_id, "member").await;
670
671 let thread_id = h
672 .create_thread_with_post(cat_id, user_id, "Render Test", "Quotable content here")
673 .await;
674
675 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
676 .await
677 .unwrap();
678 let post_id = posts[0].id;
679
680 let quoted_text = "Quotable content here";
681 let hash = compute_quote_hash(quoted_text);
682
683 let body = format!("> {quoted_text}\n[quote:{post_id}:{hash}]\n\nGreat point!");
684 let encoded_body = urlencoding::encode(&body);
685
686 let thread_url = format!("/p/test/general/{thread_id}");
687 h.client.get(&thread_url).await;
688
689 let reply_url = format!("/p/test/general/{thread_id}/reply");
690 let resp = h
691 .client
692 .post_form(&reply_url, &format!("body={encoded_body}"))
693 .await;
694 assert!(resp.status.is_redirection(), "Quote reply should succeed");
695
696 // Load the thread page and check for attribution
697 let resp = h.client.get(&thread_url).await;
698 assert!(
699 resp.text.contains("quote-attribution"),
700 "Thread page should contain quote attribution"
701 );
702 }
703
704 // Inline field-error contract, 422 + X-Form-Field so mt.js can render the
705 // message next to the offending input instead of as a transient toast.
706
707 #[tokio::test]
708 async fn empty_title_returns_inline_field_error() {
709 let mut h = TestHarness::new().await;
710 let user_id = h.login_as("fieldtitle").await;
711 let comm_id = h.create_community("Test", "test").await;
712 let _cat_id = h.create_category(comm_id, "General", "general").await;
713 h.add_membership(user_id, comm_id, "member").await;
714
715 h.client.get("/p/test/general/new").await;
716 let resp = h
717 .client
718 .post_form("/p/test/general/new", "title=&body=Some+real+body")
719 .await;
720
721 assert_eq!(resp.status.as_u16(), 422, "Empty title should be rejected");
722 assert_eq!(
723 resp.headers
724 .get("X-Form-Field")
725 .and_then(|v| v.to_str().ok()),
726 Some("title"),
727 "Validation error must name the title field for inline display"
728 );
729 assert!(
730 resp.text.contains("Title"),
731 "Error body should carry the human message, got: {}",
732 resp.text
733 );
734 }
735
736 #[tokio::test]
737 async fn empty_reply_body_returns_inline_field_error() {
738 let mut h = TestHarness::new().await;
739 let user_id = h.login_as("fieldbody").await;
740 let comm_id = h.create_community("Test", "test").await;
741 let cat_id = h.create_category(comm_id, "General", "general").await;
742 h.add_membership(user_id, comm_id, "member").await;
743 let thread_id = h
744 .create_thread_with_post(cat_id, user_id, "Thread", "OP body")
745 .await;
746
747 let thread_url = format!("/p/test/general/{thread_id}");
748 h.client.get(&thread_url).await;
749 let resp = h
750 .client
751 .post_form(&format!("{thread_url}/reply"), "body=")
752 .await;
753
754 assert_eq!(
755 resp.status.as_u16(),
756 422,
757 "Empty reply body should be rejected"
758 );
759 assert_eq!(
760 resp.headers
761 .get("X-Form-Field")
762 .and_then(|v| v.to_str().ok()),
763 Some("body"),
764 "Validation error must name the body field for inline display"
765 );
766 }
767