Skip to main content

max / multithreaded

21.5 KB · 741 lines History Blame Raw
1 //! Direct tests for mt_db::mutations functions.
2 //!
3 //! These tests exercise database mutation functions that lack integration
4 //! test coverage through route-level tests.
5
6 use crate::harness::TestHarness;
7 use mt_core::types::BanType;
8 use uuid::Uuid;
9
10 // ============================================================================
11 // Ban cleanup
12 // ============================================================================
13
14 #[tokio::test]
15 async fn cleanup_expired_bans_removes_expired() {
16 let h = TestHarness::new().await;
17 let comm_id = h.create_community("Test", "test").await;
18
19 let user1 = Uuid::new_v4();
20 let user2 = Uuid::new_v4();
21 let admin = Uuid::new_v4();
22
23 for (id, name) in [(user1, "user1"), (user2, "user2"), (admin, "admin")] {
24 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
25 .bind(id)
26 .bind(name)
27 .execute(&h.db)
28 .await
29 .unwrap();
30 }
31
32 // Create expired ban
33 sqlx::query(
34 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
35 VALUES ($1, $2, $3, 'ban', now() - interval '1 day')",
36 )
37 .bind(comm_id)
38 .bind(user1)
39 .bind(admin)
40 .execute(&h.db)
41 .await
42 .unwrap();
43
44 // Create active ban
45 sqlx::query(
46 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
47 VALUES ($1, $2, $3, 'ban', now() + interval '1 day')",
48 )
49 .bind(comm_id)
50 .bind(user2)
51 .bind(admin)
52 .execute(&h.db)
53 .await
54 .unwrap();
55
56 let cleaned = mt_db::mutations::cleanup_expired_bans(&h.db, comm_id)
57 .await
58 .unwrap();
59 assert_eq!(cleaned, 1, "Should remove 1 expired ban");
60
61 let remaining: i64 =
62 sqlx::query_scalar("SELECT COUNT(*) FROM community_bans WHERE community_id = $1")
63 .bind(comm_id)
64 .fetch_one(&h.db)
65 .await
66 .unwrap();
67 assert_eq!(remaining, 1, "Should keep 1 active ban");
68 }
69
70 #[tokio::test]
71 async fn cleanup_expired_bans_keeps_permanent() {
72 let h = TestHarness::new().await;
73 let comm_id = h.create_community("Test", "test").await;
74
75 let user = Uuid::new_v4();
76 let admin = Uuid::new_v4();
77
78 for (id, name) in [(user, "user"), (admin, "admin")] {
79 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
80 .bind(id)
81 .bind(name)
82 .execute(&h.db)
83 .await
84 .unwrap();
85 }
86
87 // Create permanent ban (no expires_at)
88 mt_db::mutations::create_community_ban(
89 &h.db,
90 comm_id,
91 user,
92 admin,
93 BanType::Ban,
94 Some("permanent"),
95 None,
96 )
97 .await
98 .unwrap();
99
100 let cleaned = mt_db::mutations::cleanup_expired_bans(&h.db, comm_id)
101 .await
102 .unwrap();
103 assert_eq!(cleaned, 0, "Should not remove permanent bans");
104 }
105
106 #[tokio::test]
107 async fn create_community_ban_upserts_on_conflict() {
108 let h = TestHarness::new().await;
109 let comm_id = h.create_community("Test", "test").await;
110
111 let user = Uuid::new_v4();
112 let admin = Uuid::new_v4();
113
114 for (id, name) in [(user, "user"), (admin, "admin")] {
115 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
116 .bind(id)
117 .bind(name)
118 .execute(&h.db)
119 .await
120 .unwrap();
121 }
122
123 // First ban
124 let id1 = mt_db::mutations::create_community_ban(
125 &h.db,
126 comm_id,
127 user,
128 admin,
129 BanType::Ban,
130 Some("first reason"),
131 None,
132 )
133 .await
134 .unwrap();
135
136 // Second ban (same user+type) should upsert, not duplicate
137 let id2 = mt_db::mutations::create_community_ban(
138 &h.db,
139 comm_id,
140 user,
141 admin,
142 BanType::Ban,
143 Some("updated reason"),
144 None,
145 )
146 .await
147 .unwrap();
148
149 assert_eq!(id1, id2, "Upsert should return same ban ID");
150
151 // Verify reason updated
152 let reason: Option<String> = sqlx::query_scalar(
153 "SELECT reason FROM community_bans WHERE id = $1",
154 )
155 .bind(id1)
156 .fetch_one(&h.db)
157 .await
158 .unwrap();
159 assert_eq!(reason.as_deref(), Some("updated reason"));
160 }
161
162 // ============================================================================
163 // Category mutations
164 // ============================================================================
165
166 #[tokio::test]
167 async fn swap_category_order_atomic() {
168 let h = TestHarness::new().await;
169 let comm_id = h.create_community("Test", "test").await;
170
171 let cat_a = mt_db::mutations::create_category(
172 &h.db, comm_id, "Alpha", "alpha", None, 0,
173 )
174 .await
175 .unwrap();
176 let cat_b = mt_db::mutations::create_category(
177 &h.db, comm_id, "Beta", "beta", None, 1,
178 )
179 .await
180 .unwrap();
181
182 mt_db::mutations::swap_category_order(&h.db, cat_a, 0, cat_b, 1)
183 .await
184 .unwrap();
185
186 let order_a: i32 =
187 sqlx::query_scalar("SELECT sort_order FROM categories WHERE id = $1")
188 .bind(cat_a)
189 .fetch_one(&h.db)
190 .await
191 .unwrap();
192 let order_b: i32 =
193 sqlx::query_scalar("SELECT sort_order FROM categories WHERE id = $1")
194 .bind(cat_b)
195 .fetch_one(&h.db)
196 .await
197 .unwrap();
198
199 assert_eq!(order_a, 1, "Alpha should now have order 1");
200 assert_eq!(order_b, 0, "Beta should now have order 0");
201 }
202
203 #[tokio::test]
204 async fn get_category_id_by_slugs_found() {
205 let h = TestHarness::new().await;
206 let comm_id = h.create_community("Test", "test").await;
207 let cat_id = h.create_category(comm_id, "General", "general").await;
208
209 let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "test", "general")
210 .await
211 .unwrap();
212 assert_eq!(found, Some(cat_id));
213 }
214
215 #[tokio::test]
216 async fn get_category_id_by_slugs_not_found() {
217 let h = TestHarness::new().await;
218 let _comm_id = h.create_community("Test", "test").await;
219
220 let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "test", "nonexistent")
221 .await
222 .unwrap();
223 assert_eq!(found, None);
224
225 let found = mt_db::mutations::get_category_id_by_slugs(&h.db, "nosuchcommunity", "general")
226 .await
227 .unwrap();
228 assert_eq!(found, None);
229 }
230
231 #[tokio::test]
232 async fn update_category_updates_fields() {
233 let h = TestHarness::new().await;
234 let comm_id = h.create_community("Test", "test").await;
235 let cat_id = mt_db::mutations::create_category(
236 &h.db, comm_id, "Old Name", "oldslug", Some("Old desc"), 0,
237 )
238 .await
239 .unwrap();
240
241 mt_db::mutations::update_category(&h.db, cat_id, "New Name", Some("New desc"))
242 .await
243 .unwrap();
244
245 let (name, desc): (String, Option<String>) = sqlx::query_as(
246 "SELECT name, description FROM categories WHERE id = $1",
247 )
248 .bind(cat_id)
249 .fetch_one(&h.db)
250 .await
251 .unwrap();
252
253 assert_eq!(name, "New Name");
254 assert_eq!(desc.as_deref(), Some("New desc"));
255 }
256
257 // ============================================================================
258 // Membership mutations
259 // ============================================================================
260
261 #[tokio::test]
262 async fn ensure_membership_idempotent() {
263 let h = TestHarness::new().await;
264 let comm_id = h.create_community("Test", "test").await;
265
266 let user = Uuid::new_v4();
267 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
268 .bind(user)
269 .bind("testuser")
270 .execute(&h.db)
271 .await
272 .unwrap();
273
274 // First call creates membership
275 mt_db::mutations::ensure_membership(&h.db, user, comm_id)
276 .await
277 .unwrap();
278
279 // Second call should succeed (ON CONFLICT DO NOTHING)
280 mt_db::mutations::ensure_membership(&h.db, user, comm_id)
281 .await
282 .unwrap();
283
284 let count: i64 = sqlx::query_scalar(
285 "SELECT COUNT(*) FROM memberships WHERE user_id = $1 AND community_id = $2",
286 )
287 .bind(user)
288 .bind(comm_id)
289 .fetch_one(&h.db)
290 .await
291 .unwrap();
292 assert_eq!(count, 1, "Should have exactly one membership row");
293 }
294
295 #[tokio::test]
296 async fn ensure_membership_with_role_idempotent() {
297 let h = TestHarness::new().await;
298 let comm_id = h.create_community("Test", "test").await;
299
300 let user = Uuid::new_v4();
301 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
302 .bind(user)
303 .bind("roleuser")
304 .execute(&h.db)
305 .await
306 .unwrap();
307
308 mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
309 .await
310 .unwrap();
311
312 // Second call with same role should succeed
313 mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
314 .await
315 .unwrap();
316
317 // Verify role is preserved (DO NOTHING means first write wins)
318 let role: String = sqlx::query_scalar(
319 "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2",
320 )
321 .bind(user)
322 .bind(comm_id)
323 .fetch_one(&h.db)
324 .await
325 .unwrap();
326 assert_eq!(role, "moderator");
327 }
328
329 // ============================================================================
330 // Thread mutations
331 // ============================================================================
332
333 #[tokio::test]
334 async fn soft_delete_sets_deleted_at() {
335 let h = TestHarness::new().await;
336 let comm_id = h.create_community("Test", "test").await;
337 let cat_id = h.create_category(comm_id, "General", "general").await;
338
339 let author = Uuid::new_v4();
340 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
341 .bind(author)
342 .bind("author")
343 .execute(&h.db)
344 .await
345 .unwrap();
346
347 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Delete Me")
348 .await
349 .unwrap();
350
351 // Verify not deleted
352 let deleted: bool = sqlx::query_scalar(
353 "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
354 )
355 .bind(thread_id)
356 .fetch_one(&h.db)
357 .await
358 .unwrap();
359 assert!(!deleted, "Should not be deleted initially");
360
361 mt_db::mutations::soft_delete_thread(&h.db, thread_id)
362 .await
363 .unwrap();
364
365 let deleted: bool = sqlx::query_scalar(
366 "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
367 )
368 .bind(thread_id)
369 .fetch_one(&h.db)
370 .await
371 .unwrap();
372 assert!(deleted, "Should be soft-deleted");
373 }
374
375 #[tokio::test]
376 async fn create_post_bumps_thread_activity() {
377 let h = TestHarness::new().await;
378 let comm_id = h.create_community("Test", "test").await;
379 let cat_id = h.create_category(comm_id, "General", "general").await;
380
381 let author = Uuid::new_v4();
382 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
383 .bind(author)
384 .bind("author")
385 .execute(&h.db)
386 .await
387 .unwrap();
388
389 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Activity Test")
390 .await
391 .unwrap();
392
393 // Set activity to a known old time
394 sqlx::query(
395 "UPDATE threads SET last_activity_at = '2000-01-01'::timestamptz WHERE id = $1",
396 )
397 .bind(thread_id)
398 .execute(&h.db)
399 .await
400 .unwrap();
401
402 // Create post should bump last_activity_at
403 mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>")
404 .await
405 .unwrap();
406
407 let recent: bool = sqlx::query_scalar(
408 "SELECT last_activity_at > '2020-01-01'::timestamptz FROM threads WHERE id = $1",
409 )
410 .bind(thread_id)
411 .fetch_one(&h.db)
412 .await
413 .unwrap();
414 assert!(recent, "last_activity_at should be updated to recent time");
415 }
416
417 // ============================================================================
418 // Endorsement mutations
419 // ============================================================================
420
421 #[tokio::test]
422 async fn toggle_endorsement_db_roundtrip() {
423 let h = TestHarness::new().await;
424 let comm_id = h.create_community("Test", "test").await;
425 let cat_id = h.create_category(comm_id, "General", "general").await;
426
427 let author = Uuid::new_v4();
428 let endorser = Uuid::new_v4();
429 for (id, name) in [(author, "author"), (endorser, "endorser")] {
430 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
431 .bind(id)
432 .bind(name)
433 .execute(&h.db)
434 .await
435 .unwrap();
436 }
437
438 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
439 .await
440 .unwrap();
441 let post_id = mt_db::mutations::create_post(
442 &h.db, thread_id, author, "content", "<p>content</p>",
443 )
444 .await
445 .unwrap();
446
447 // First toggle: endorse
448 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
449 .await
450 .unwrap();
451 assert!(result, "First toggle should endorse (return true)");
452
453 // Second toggle: un-endorse
454 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
455 .await
456 .unwrap();
457 assert!(!result, "Second toggle should un-endorse (return false)");
458
459 // Third toggle: endorse again
460 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
461 .await
462 .unwrap();
463 assert!(result, "Third toggle should endorse again (return true)");
464 }
465
466 // ============================================================================
467 // Flag mutations
468 // ============================================================================
469
470 #[tokio::test]
471 async fn insert_flag_idempotent_per_user() {
472 let h = TestHarness::new().await;
473 let comm_id = h.create_community("Test", "test").await;
474 let cat_id = h.create_category(comm_id, "General", "general").await;
475
476 let author = Uuid::new_v4();
477 let flagger = Uuid::new_v4();
478 for (id, name) in [(author, "author"), (flagger, "flagger")] {
479 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
480 .bind(id)
481 .bind(name)
482 .execute(&h.db)
483 .await
484 .unwrap();
485 }
486
487 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
488 .await
489 .unwrap();
490 let post_id = mt_db::mutations::create_post(
491 &h.db, thread_id, author, "content", "<p>content</p>",
492 )
493 .await
494 .unwrap();
495
496 // First flag
497 mt_db::mutations::insert_flag(&h.db, post_id, flagger, "spam", None)
498 .await
499 .unwrap();
500
501 // Second flag from same user — ON CONFLICT DO NOTHING
502 mt_db::mutations::insert_flag(&h.db, post_id, flagger, "off_topic", Some("detail"))
503 .await
504 .unwrap();
505
506 let count: i64 = sqlx::query_scalar(
507 "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND flagger_id = $2",
508 )
509 .bind(post_id)
510 .bind(flagger)
511 .fetch_one(&h.db)
512 .await
513 .unwrap();
514 assert_eq!(count, 1, "Should have exactly 1 flag per user per post");
515 }
516
517 // ============================================================================
518 // Image mutations
519 // ============================================================================
520
521 #[tokio::test]
522 async fn remove_image_marks_removed() {
523 let h = TestHarness::new().await;
524 let comm_id = h.create_community("Test", "test").await;
525
526 let uploader = Uuid::new_v4();
527 let moderator = Uuid::new_v4();
528 for (id, name) in [(uploader, "uploader"), (moderator, "moderator")] {
529 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
530 .bind(id)
531 .bind(name)
532 .execute(&h.db)
533 .await
534 .unwrap();
535 }
536
537 let image_id = mt_db::mutations::insert_image(
538 &h.db, uploader, comm_id, "s3/key.jpg", "photo.jpg", "image/jpeg", 12345,
539 )
540 .await
541 .unwrap();
542
543 // Verify not removed
544 let removed: bool = sqlx::query_scalar(
545 "SELECT removed_at IS NOT NULL FROM images WHERE id = $1",
546 )
547 .bind(image_id)
548 .fetch_one(&h.db)
549 .await
550 .unwrap();
551 assert!(!removed, "Should not be removed initially");
552
553 mt_db::mutations::remove_image(&h.db, image_id, moderator)
554 .await
555 .unwrap();
556
557 let (removed, removed_by): (bool, Option<Uuid>) = sqlx::query_as(
558 "SELECT removed_at IS NOT NULL, removed_by FROM images WHERE id = $1",
559 )
560 .bind(image_id)
561 .fetch_one(&h.db)
562 .await
563 .unwrap();
564 assert!(removed, "Should be marked as removed");
565 assert_eq!(removed_by, Some(moderator), "removed_by should match moderator");
566 }
567
568 #[tokio::test]
569 async fn remove_image_idempotent() {
570 let h = TestHarness::new().await;
571 let comm_id = h.create_community("Test", "test").await;
572
573 let uploader = Uuid::new_v4();
574 let mod1 = Uuid::new_v4();
575 let mod2 = Uuid::new_v4();
576 for (id, name) in [(uploader, "uploader"), (mod1, "mod1"), (mod2, "mod2")] {
577 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
578 .bind(id)
579 .bind(name)
580 .execute(&h.db)
581 .await
582 .unwrap();
583 }
584
585 let image_id = mt_db::mutations::insert_image(
586 &h.db, uploader, comm_id, "s3/key.jpg", "photo.jpg", "image/jpeg", 100,
587 )
588 .await
589 .unwrap();
590
591 // First remove
592 mt_db::mutations::remove_image(&h.db, image_id, mod1)
593 .await
594 .unwrap();
595
596 // Second remove (different mod) — should be no-op (WHERE removed_at IS NULL)
597 mt_db::mutations::remove_image(&h.db, image_id, mod2)
598 .await
599 .unwrap();
600
601 // Verify original remover preserved
602 let removed_by: Option<Uuid> = sqlx::query_scalar(
603 "SELECT removed_by FROM images WHERE id = $1",
604 )
605 .bind(image_id)
606 .fetch_one(&h.db)
607 .await
608 .unwrap();
609 assert_eq!(removed_by, Some(mod1), "First remover should be preserved");
610 }
611
612 // ============================================================================
613 // Link preview mutations
614 // ============================================================================
615
616 #[tokio::test]
617 async fn insert_link_preview_dedup() {
618 let h = TestHarness::new().await;
619 let comm_id = h.create_community("Test", "test").await;
620 let cat_id = h.create_category(comm_id, "General", "general").await;
621
622 let author = Uuid::new_v4();
623 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
624 .bind(author)
625 .bind("author")
626 .execute(&h.db)
627 .await
628 .unwrap();
629
630 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
631 .await
632 .unwrap();
633 let post_id = mt_db::mutations::create_post(
634 &h.db, thread_id, author, "content", "<p>content</p>",
635 )
636 .await
637 .unwrap();
638
639 mt_db::mutations::insert_link_preview(
640 &h.db, post_id, "https://example.com", Some("Example"), Some("A site"),
641 )
642 .await
643 .unwrap();
644
645 // Duplicate — ON CONFLICT DO NOTHING
646 mt_db::mutations::insert_link_preview(
647 &h.db, post_id, "https://example.com", Some("Different Title"), None,
648 )
649 .await
650 .unwrap();
651
652 let count: i64 = sqlx::query_scalar(
653 "SELECT COUNT(*) FROM link_previews WHERE post_id = $1",
654 )
655 .bind(post_id)
656 .fetch_one(&h.db)
657 .await
658 .unwrap();
659 assert_eq!(count, 1, "Should have exactly 1 link preview per URL per post");
660 }
661
662 // ============================================================================
663 // Mention mutations
664 // ============================================================================
665
666 #[tokio::test]
667 async fn insert_mentions_dedup() {
668 let h = TestHarness::new().await;
669 let comm_id = h.create_community("Test", "test").await;
670 let cat_id = h.create_category(comm_id, "General", "general").await;
671
672 let author = Uuid::new_v4();
673 let mentioned = Uuid::new_v4();
674 for (id, name) in [(author, "author"), (mentioned, "mentioned")] {
675 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
676 .bind(id)
677 .bind(name)
678 .execute(&h.db)
679 .await
680 .unwrap();
681 }
682
683 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
684 .await
685 .unwrap();
686 let post_id = mt_db::mutations::create_post(
687 &h.db, thread_id, author, "content", "<p>content</p>",
688 )
689 .await
690 .unwrap();
691
692 // Insert mentions
693 mt_db::mutations::insert_mentions(&h.db, post_id, &[mentioned])
694 .await
695 .unwrap();
696
697 // Insert same mention again — ON CONFLICT DO NOTHING
698 mt_db::mutations::insert_mentions(&h.db, post_id, &[mentioned])
699 .await
700 .unwrap();
701
702 let count: i64 = sqlx::query_scalar(
703 "SELECT COUNT(*) FROM post_mentions WHERE post_id = $1",
704 )
705 .bind(post_id)
706 .fetch_one(&h.db)
707 .await
708 .unwrap();
709 assert_eq!(count, 1, "Should have exactly 1 mention row per user per post");
710 }
711
712 // ============================================================================
713 // Upsert user
714 // ============================================================================
715
716 #[tokio::test]
717 async fn upsert_user_updates_on_conflict() {
718 let h = TestHarness::new().await;
719 let user_id = Uuid::new_v4();
720
721 mt_db::mutations::upsert_user(&h.db, user_id, "oldname", Some("Old Display"))
722 .await
723 .unwrap();
724
725 // Upsert with new values
726 mt_db::mutations::upsert_user(&h.db, user_id, "newname", Some("New Display"))
727 .await
728 .unwrap();
729
730 let (username, display_name): (String, Option<String>) = sqlx::query_as(
731 "SELECT username, display_name FROM users WHERE mnw_account_id = $1",
732 )
733 .bind(user_id)
734 .fetch_one(&h.db)
735 .await
736 .unwrap();
737
738 assert_eq!(username, "newname");
739 assert_eq!(display_name.as_deref(), Some("New Display"));
740 }
741