Skip to main content

max / makenotwork

21.5 KB · 742 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 let updated = mt_db::mutations::update_category(&h.db, cat_id, comm_id, "New Name", Some("New desc"))
242 .await
243 .unwrap();
244 assert!(updated);
245
246 let (name, desc): (String, Option<String>) = sqlx::query_as(
247 "SELECT name, description FROM categories WHERE id = $1",
248 )
249 .bind(cat_id)
250 .fetch_one(&h.db)
251 .await
252 .unwrap();
253
254 assert_eq!(name, "New Name");
255 assert_eq!(desc.as_deref(), Some("New desc"));
256 }
257
258 // ============================================================================
259 // Membership mutations
260 // ============================================================================
261
262 #[tokio::test]
263 async fn ensure_membership_idempotent() {
264 let h = TestHarness::new().await;
265 let comm_id = h.create_community("Test", "test").await;
266
267 let user = Uuid::new_v4();
268 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
269 .bind(user)
270 .bind("testuser")
271 .execute(&h.db)
272 .await
273 .unwrap();
274
275 // First call creates membership
276 mt_db::mutations::ensure_membership(&h.db, user, comm_id)
277 .await
278 .unwrap();
279
280 // Second call should succeed (ON CONFLICT DO NOTHING)
281 mt_db::mutations::ensure_membership(&h.db, user, comm_id)
282 .await
283 .unwrap();
284
285 let count: i64 = sqlx::query_scalar(
286 "SELECT COUNT(*) FROM memberships WHERE user_id = $1 AND community_id = $2",
287 )
288 .bind(user)
289 .bind(comm_id)
290 .fetch_one(&h.db)
291 .await
292 .unwrap();
293 assert_eq!(count, 1, "Should have exactly one membership row");
294 }
295
296 #[tokio::test]
297 async fn ensure_membership_with_role_idempotent() {
298 let h = TestHarness::new().await;
299 let comm_id = h.create_community("Test", "test").await;
300
301 let user = Uuid::new_v4();
302 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
303 .bind(user)
304 .bind("roleuser")
305 .execute(&h.db)
306 .await
307 .unwrap();
308
309 mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
310 .await
311 .unwrap();
312
313 // Second call with same role should succeed
314 mt_db::mutations::ensure_membership_with_role(&h.db, user, comm_id, "moderator")
315 .await
316 .unwrap();
317
318 // Verify role is preserved (DO NOTHING means first write wins)
319 let role: String = sqlx::query_scalar(
320 "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2",
321 )
322 .bind(user)
323 .bind(comm_id)
324 .fetch_one(&h.db)
325 .await
326 .unwrap();
327 assert_eq!(role, "moderator");
328 }
329
330 // ============================================================================
331 // Thread mutations
332 // ============================================================================
333
334 #[tokio::test]
335 async fn soft_delete_sets_deleted_at() {
336 let h = TestHarness::new().await;
337 let comm_id = h.create_community("Test", "test").await;
338 let cat_id = h.create_category(comm_id, "General", "general").await;
339
340 let author = Uuid::new_v4();
341 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
342 .bind(author)
343 .bind("author")
344 .execute(&h.db)
345 .await
346 .unwrap();
347
348 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Delete Me")
349 .await
350 .unwrap();
351
352 // Verify not deleted
353 let deleted: bool = sqlx::query_scalar(
354 "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
355 )
356 .bind(thread_id)
357 .fetch_one(&h.db)
358 .await
359 .unwrap();
360 assert!(!deleted, "Should not be deleted initially");
361
362 mt_db::mutations::soft_delete_thread(&h.db, thread_id)
363 .await
364 .unwrap();
365
366 let deleted: bool = sqlx::query_scalar(
367 "SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1",
368 )
369 .bind(thread_id)
370 .fetch_one(&h.db)
371 .await
372 .unwrap();
373 assert!(deleted, "Should be soft-deleted");
374 }
375
376 #[tokio::test]
377 async fn create_post_bumps_thread_activity() {
378 let h = TestHarness::new().await;
379 let comm_id = h.create_community("Test", "test").await;
380 let cat_id = h.create_category(comm_id, "General", "general").await;
381
382 let author = Uuid::new_v4();
383 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
384 .bind(author)
385 .bind("author")
386 .execute(&h.db)
387 .await
388 .unwrap();
389
390 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Activity Test")
391 .await
392 .unwrap();
393
394 // Set activity to a known old time
395 sqlx::query(
396 "UPDATE threads SET last_activity_at = '2000-01-01'::timestamptz WHERE id = $1",
397 )
398 .bind(thread_id)
399 .execute(&h.db)
400 .await
401 .unwrap();
402
403 // Create post should bump last_activity_at
404 mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>", true)
405 .await
406 .unwrap();
407
408 let recent: bool = sqlx::query_scalar(
409 "SELECT last_activity_at > '2020-01-01'::timestamptz FROM threads WHERE id = $1",
410 )
411 .bind(thread_id)
412 .fetch_one(&h.db)
413 .await
414 .unwrap();
415 assert!(recent, "last_activity_at should be updated to recent time");
416 }
417
418 // ============================================================================
419 // Endorsement mutations
420 // ============================================================================
421
422 #[tokio::test]
423 async fn toggle_endorsement_db_roundtrip() {
424 let h = TestHarness::new().await;
425 let comm_id = h.create_community("Test", "test").await;
426 let cat_id = h.create_category(comm_id, "General", "general").await;
427
428 let author = Uuid::new_v4();
429 let endorser = Uuid::new_v4();
430 for (id, name) in [(author, "author"), (endorser, "endorser")] {
431 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
432 .bind(id)
433 .bind(name)
434 .execute(&h.db)
435 .await
436 .unwrap();
437 }
438
439 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
440 .await
441 .unwrap();
442 let post_id = mt_db::mutations::create_post(
443 &h.db, thread_id, author, "content", "<p>content</p>", true,
444 )
445 .await
446 .unwrap();
447
448 // First toggle: endorse
449 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
450 .await
451 .unwrap();
452 assert!(result, "First toggle should endorse (return true)");
453
454 // Second toggle: un-endorse
455 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
456 .await
457 .unwrap();
458 assert!(!result, "Second toggle should un-endorse (return false)");
459
460 // Third toggle: endorse again
461 let result = mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser)
462 .await
463 .unwrap();
464 assert!(result, "Third toggle should endorse again (return true)");
465 }
466
467 // ============================================================================
468 // Flag mutations
469 // ============================================================================
470
471 #[tokio::test]
472 async fn insert_flag_idempotent_per_user() {
473 let h = TestHarness::new().await;
474 let comm_id = h.create_community("Test", "test").await;
475 let cat_id = h.create_category(comm_id, "General", "general").await;
476
477 let author = Uuid::new_v4();
478 let flagger = Uuid::new_v4();
479 for (id, name) in [(author, "author"), (flagger, "flagger")] {
480 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
481 .bind(id)
482 .bind(name)
483 .execute(&h.db)
484 .await
485 .unwrap();
486 }
487
488 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
489 .await
490 .unwrap();
491 let post_id = mt_db::mutations::create_post(
492 &h.db, thread_id, author, "content", "<p>content</p>", true,
493 )
494 .await
495 .unwrap();
496
497 // First flag
498 mt_db::mutations::insert_flag(&h.db, post_id, flagger, "spam", None)
499 .await
500 .unwrap();
501
502 // Second flag from same user — ON CONFLICT DO NOTHING
503 mt_db::mutations::insert_flag(&h.db, post_id, flagger, "off_topic", Some("detail"))
504 .await
505 .unwrap();
506
507 let count: i64 = sqlx::query_scalar(
508 "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND flagger_id = $2",
509 )
510 .bind(post_id)
511 .bind(flagger)
512 .fetch_one(&h.db)
513 .await
514 .unwrap();
515 assert_eq!(count, 1, "Should have exactly 1 flag per user per post");
516 }
517
518 // ============================================================================
519 // Image mutations
520 // ============================================================================
521
522 #[tokio::test]
523 async fn remove_image_marks_removed() {
524 let h = TestHarness::new().await;
525 let comm_id = h.create_community("Test", "test").await;
526
527 let uploader = Uuid::new_v4();
528 let moderator = Uuid::new_v4();
529 for (id, name) in [(uploader, "uploader"), (moderator, "moderator")] {
530 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
531 .bind(id)
532 .bind(name)
533 .execute(&h.db)
534 .await
535 .unwrap();
536 }
537
538 let image_id = mt_db::mutations::insert_image(
539 &h.db, uploader, comm_id, "s3/key.jpg", "photo.jpg", "image/jpeg", 12345,
540 )
541 .await
542 .unwrap();
543
544 // Verify not removed
545 let removed: bool = sqlx::query_scalar(
546 "SELECT removed_at IS NOT NULL FROM images WHERE id = $1",
547 )
548 .bind(image_id)
549 .fetch_one(&h.db)
550 .await
551 .unwrap();
552 assert!(!removed, "Should not be removed initially");
553
554 mt_db::mutations::remove_image(&h.db, image_id, moderator)
555 .await
556 .unwrap();
557
558 let (removed, removed_by): (bool, Option<Uuid>) = sqlx::query_as(
559 "SELECT removed_at IS NOT NULL, removed_by FROM images WHERE id = $1",
560 )
561 .bind(image_id)
562 .fetch_one(&h.db)
563 .await
564 .unwrap();
565 assert!(removed, "Should be marked as removed");
566 assert_eq!(removed_by, Some(moderator), "removed_by should match moderator");
567 }
568
569 #[tokio::test]
570 async fn remove_image_idempotent() {
571 let h = TestHarness::new().await;
572 let comm_id = h.create_community("Test", "test").await;
573
574 let uploader = Uuid::new_v4();
575 let mod1 = Uuid::new_v4();
576 let mod2 = Uuid::new_v4();
577 for (id, name) in [(uploader, "uploader"), (mod1, "mod1"), (mod2, "mod2")] {
578 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
579 .bind(id)
580 .bind(name)
581 .execute(&h.db)
582 .await
583 .unwrap();
584 }
585
586 let image_id = mt_db::mutations::insert_image(
587 &h.db, uploader, comm_id, "s3/key.jpg", "photo.jpg", "image/jpeg", 100,
588 )
589 .await
590 .unwrap();
591
592 // First remove
593 mt_db::mutations::remove_image(&h.db, image_id, mod1)
594 .await
595 .unwrap();
596
597 // Second remove (different mod) — should be no-op (WHERE removed_at IS NULL)
598 mt_db::mutations::remove_image(&h.db, image_id, mod2)
599 .await
600 .unwrap();
601
602 // Verify original remover preserved
603 let removed_by: Option<Uuid> = sqlx::query_scalar(
604 "SELECT removed_by FROM images WHERE id = $1",
605 )
606 .bind(image_id)
607 .fetch_one(&h.db)
608 .await
609 .unwrap();
610 assert_eq!(removed_by, Some(mod1), "First remover should be preserved");
611 }
612
613 // ============================================================================
614 // Link preview mutations
615 // ============================================================================
616
617 #[tokio::test]
618 async fn insert_link_preview_dedup() {
619 let h = TestHarness::new().await;
620 let comm_id = h.create_community("Test", "test").await;
621 let cat_id = h.create_category(comm_id, "General", "general").await;
622
623 let author = Uuid::new_v4();
624 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
625 .bind(author)
626 .bind("author")
627 .execute(&h.db)
628 .await
629 .unwrap();
630
631 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
632 .await
633 .unwrap();
634 let post_id = mt_db::mutations::create_post(
635 &h.db, thread_id, author, "content", "<p>content</p>", true,
636 )
637 .await
638 .unwrap();
639
640 mt_db::mutations::insert_link_preview(
641 &h.db, post_id, "https://example.com", Some("Example"), Some("A site"),
642 )
643 .await
644 .unwrap();
645
646 // Duplicate — ON CONFLICT DO NOTHING
647 mt_db::mutations::insert_link_preview(
648 &h.db, post_id, "https://example.com", Some("Different Title"), None,
649 )
650 .await
651 .unwrap();
652
653 let count: i64 = sqlx::query_scalar(
654 "SELECT COUNT(*) FROM link_previews WHERE post_id = $1",
655 )
656 .bind(post_id)
657 .fetch_one(&h.db)
658 .await
659 .unwrap();
660 assert_eq!(count, 1, "Should have exactly 1 link preview per URL per post");
661 }
662
663 // ============================================================================
664 // Mention mutations
665 // ============================================================================
666
667 #[tokio::test]
668 async fn insert_mentions_dedup() {
669 let h = TestHarness::new().await;
670 let comm_id = h.create_community("Test", "test").await;
671 let cat_id = h.create_category(comm_id, "General", "general").await;
672
673 let author = Uuid::new_v4();
674 let mentioned = Uuid::new_v4();
675 for (id, name) in [(author, "author"), (mentioned, "mentioned")] {
676 sqlx::query("INSERT INTO users (mnw_account_id, username) VALUES ($1, $2)")
677 .bind(id)
678 .bind(name)
679 .execute(&h.db)
680 .await
681 .unwrap();
682 }
683
684 let thread_id = mt_db::mutations::create_thread(&h.db, cat_id, author, "Test")
685 .await
686 .unwrap();
687 let post_id = mt_db::mutations::create_post(
688 &h.db, thread_id, author, "content", "<p>content</p>", true,
689 )
690 .await
691 .unwrap();
692
693 // Insert mentions
694 mt_db::mutations::insert_mentions(&h.db, post_id, &[mentioned])
695 .await
696 .unwrap();
697
698 // Insert same mention again — ON CONFLICT DO NOTHING
699 mt_db::mutations::insert_mentions(&h.db, post_id, &[mentioned])
700 .await
701 .unwrap();
702
703 let count: i64 = sqlx::query_scalar(
704 "SELECT COUNT(*) FROM post_mentions WHERE post_id = $1",
705 )
706 .bind(post_id)
707 .fetch_one(&h.db)
708 .await
709 .unwrap();
710 assert_eq!(count, 1, "Should have exactly 1 mention row per user per post");
711 }
712
713 // ============================================================================
714 // Upsert user
715 // ============================================================================
716
717 #[tokio::test]
718 async fn upsert_user_updates_on_conflict() {
719 let h = TestHarness::new().await;
720 let user_id = Uuid::new_v4();
721
722 mt_db::mutations::upsert_user(&h.db, user_id, "oldname", Some("Old Display"))
723 .await
724 .unwrap();
725
726 // Upsert with new values
727 mt_db::mutations::upsert_user(&h.db, user_id, "newname", Some("New Display"))
728 .await
729 .unwrap();
730
731 let (username, display_name): (String, Option<String>) = sqlx::query_as(
732 "SELECT username, display_name FROM users WHERE mnw_account_id = $1",
733 )
734 .bind(user_id)
735 .fetch_one(&h.db)
736 .await
737 .unwrap();
738
739 assert_eq!(username, "newname");
740 assert_eq!(display_name.as_deref(), Some("New Display"));
741 }
742