Skip to main content

max / makenotwork

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