use crate::harness::TestHarness; #[tokio::test] async fn track_thread_happy_path() { let mut h = TestHarness::new().await; let user_id = h.login_as("tracker").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Track Me", "Content") .await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let track_url = format!("/p/test/general/{thread_id}/track"); let resp = h.client.post_form(&track_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id) .await .unwrap(); assert!(tracked, "Thread should be tracked"); } #[tokio::test] async fn untrack_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("untracker").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Untrack Me", "Content") .await; // Track first mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let untrack_url = format!("/p/test/general/{thread_id}/untrack"); let resp = h.client.post_form(&untrack_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id) .await .unwrap(); assert!(!tracked, "Thread should not be tracked"); } #[tokio::test] async fn track_thread_via_wrong_community_slug_404s() { // C1: a thread in community A must not be trackable through community B's slug. let mut h = TestHarness::new().await; let user_id = h.login_as("crossslug").await; let comm_a = h.create_community("Alpha", "alpha").await; let cat_a = h.create_category(comm_a, "General", "general").await; h.add_membership(user_id, comm_a, "member").await; // A second community whose slug the attacker will borrow. let comm_b = h.create_community("Beta", "beta").await; h.create_category(comm_b, "General", "general").await; h.add_membership(user_id, comm_b, "member").await; let thread_id = h .create_thread_with_post(cat_a, user_id, "In Alpha", "Content") .await; // Track the alpha thread through beta's slug → 404 (mismatch is not-found). let wrong = format!("/p/beta/general/{thread_id}/track"); let resp = h.client.post_form(&wrong, "").await; assert_eq!( resp.status, axum::http::StatusCode::NOT_FOUND, "got {}", resp.status ); let tracked = mt_db::queries::is_thread_tracked(&h.db, user_id, thread_id) .await .unwrap(); assert!( !tracked, "no tracking row may be created via a foreign slug" ); } #[tokio::test] async fn banned_user_cannot_track_thread() { // A banned user must not accrue tracking state in the community they're banned from. let mut h = TestHarness::new().await; let owner_id = h.login_as("owner").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(owner_id, comm_id, "owner").await; let thread_id = h .create_thread_with_post(cat_id, owner_id, "Topic", "Content") .await; // Switch to the victim, who is a member then gets banned. let banned_id = h.login_as("banned").await; h.add_membership(banned_id, comm_id, "member").await; h.ban_user(comm_id, banned_id, owner_id, "ban").await; let track_url = format!("/p/test/general/{thread_id}/track"); let resp = h.client.post_form(&track_url, "").await; assert_eq!( resp.status, axum::http::StatusCode::FORBIDDEN, "got {}", resp.status ); let tracked = mt_db::queries::is_thread_tracked(&h.db, banned_id, thread_id) .await .unwrap(); assert!(!tracked, "banned user must not accrue tracking state"); } #[tokio::test] async fn read_position_updates_on_view() { let mut h = TestHarness::new().await; let user_id = h.login_as("readpos").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Read Pos", "First post") .await; // Track the thread mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); // View thread, should update read position. The bump is now spawned off the // response path, so poll briefly for it rather than reading immediately. let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let mut last_read: Option = None; for _ in 0..50 { let row: Option<(Option,)> = sqlx::query_as( "SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2", ) .bind(user_id) .bind(thread_id) .fetch_optional(&h.db) .await .unwrap(); assert!(row.is_some(), "Tracking row should exist"); last_read = row.unwrap().0; if last_read.is_some() { break; } tokio::time::sleep(std::time::Duration::from_millis(20)).await; } assert!( last_read.is_some(), "last_read_post_id should be set after viewing" ); } /// Regression (Run #3 re-verify, read-position monotonicity): the read position /// only moves forward. The view handler bumps to the last post on the page being /// viewed, so revisiting page 1 of a long thread must not drag the position back /// and resurrect already-read posts as unread. #[tokio::test] async fn read_position_never_moves_backward() { let mut h = TestHarness::new().await; let user_id = h.login_as("monotonic").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Monotonic", "First post") .await; let first_post: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM posts WHERE thread_id = $1 ORDER BY created_at ASC LIMIT 1", ) .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); let later_post = mt_db::mutations::create_post(&h.db, thread_id, user_id, "Later", "

Later

") .await .unwrap(); mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); let read_position = async || -> Option { sqlx::query_scalar( "SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2", ) .bind(user_id) .bind(thread_id) .fetch_one(&h.db) .await .unwrap() }; // Forward from NULL: the first bump always takes. mt_db::mutations::update_read_position(&h.db, user_id, thread_id, later_post) .await .unwrap(); assert_eq!(read_position().await, Some(later_post)); // Backward: viewing an earlier page must leave the position alone. mt_db::mutations::update_read_position(&h.db, user_id, thread_id, first_post) .await .unwrap(); assert_eq!( read_position().await, Some(later_post), "read position must not move backward to an earlier post" ); // And the unread count stays settled rather than re-surfacing read posts. let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert_eq!(tracked.len(), 1); assert_eq!( tracked[0].unread_count, 0, "a backward bump must not resurrect already-read posts as unread" ); // Re-bumping the same post is a no-op, not an error. mt_db::mutations::update_read_position(&h.db, user_id, thread_id, later_post) .await .unwrap(); assert_eq!(read_position().await, Some(later_post)); } #[tokio::test] async fn unread_count_tracking() { let mut h = TestHarness::new().await; let user_id = h.login_as("unreadcount").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Unread Count", "First post") .await; // Track and view to set read position mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; // Add a new post by another user let other_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)") .bind(other_id) .bind("poster2") .execute(&h.db) .await .unwrap(); mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "

New reply

") .await .unwrap(); // Check tracked threads, should show unread let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert_eq!(tracked.len(), 1); assert!(tracked[0].unread_count > 0, "Should have unread posts"); } /// `posts.deleted_at` is dormant today (migration 031), so this sets it directly /// rather than through a handler. That is the point: the filter has to already be /// in place on the day an author-delete path ships, or the badge starts counting /// posts the reader cannot open. `removed_at` covers the mod's action and is /// asserted alongside it so neither predicate can be dropped alone. #[tokio::test] async fn unread_count_excludes_removed_and_deleted_posts() { let mut h = TestHarness::new().await; let user_id = h.login_as("unreadfilter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Filtered", "First post") .await; mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); h.client.get(&format!("/p/test/general/{thread_id}")).await; let other_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)") .bind(other_id) .bind("filterposter") .execute(&h.db) .await .unwrap(); let removed = mt_db::mutations::create_post(&h.db, thread_id, other_id, "gone", "

gone

") .await .unwrap(); let deleted = mt_db::mutations::create_post(&h.db, thread_id, other_id, "bye", "

bye

") .await .unwrap(); let unread_before = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap()[0] .unread_count; assert_eq!(unread_before, 2, "both replies start unread"); sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1") .bind(removed) .execute(&h.db) .await .unwrap(); sqlx::query("UPDATE posts SET deleted_at = now() WHERE id = $1") .bind(deleted) .execute(&h.db) .await .unwrap(); let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert_eq!( tracked[0].unread_count, 0, "a mod-removed and an author-deleted post must both drop out of the badge" ); } /// The mention flag reads posts too, so it carries the same filter as the unread /// count. A badge pointing at a removed post sends the reader to a thread with /// nothing to find. #[tokio::test] async fn mention_flag_excludes_removed_and_deleted_posts() { let mut h = TestHarness::new().await; let user_id = h.login_as("mentionfilter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Mentioned", "First post") .await; mt_db::mutations::track_thread(&h.db, user_id, thread_id) .await .unwrap(); let other_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)") .bind(other_id) .bind("mentioner") .execute(&h.db) .await .unwrap(); let post_id = mt_db::mutations::create_post( &h.db, thread_id, other_id, "@mentionfilter hi", "

@mentionfilter hi

", ) .await .unwrap(); sqlx::query("INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2)") .bind(post_id) .bind(user_id) .execute(&h.db) .await .unwrap(); let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert!(tracked[0].has_mention, "live mention must flag"); sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1") .bind(post_id) .execute(&h.db) .await .unwrap(); let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert!( !tracked[0].has_mention, "a mention inside a removed post must not flag" ); } #[tokio::test] async fn stop_tracking_all() { let mut h = TestHarness::new().await; let user_id = h.login_as("stopall").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let t1 = h .create_thread_with_post(cat_id, user_id, "Thread 1", "content") .await; let t2 = h .create_thread_with_post(cat_id, user_id, "Thread 2", "content") .await; mt_db::mutations::track_thread(&h.db, user_id, t1) .await .unwrap(); mt_db::mutations::track_thread(&h.db, user_id, t2) .await .unwrap(); h.client.get("/tracked").await; let resp = h.client.post_form("/tracked/stop-all", "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0) .await .unwrap(); assert_eq!(tracked.len(), 0, "All tracked threads should be removed"); } #[tokio::test] async fn track_requires_login() { let mut h = TestHarness::new().await; let user_id = h.login_as("trackloginuser").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Login Track", "content") .await; // New harness without login let mut h2 = TestHarness::new().await; h2.client.get("/").await; let track_url = format!("/p/test/general/{thread_id}/track"); let resp = h2.client.post_form(&track_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect to login, got {}", resp.status ); }