//! Follow/unfollow: user follows, project follows, self-follow rejection, nonexistent target. use crate::harness::TestHarness; use serde_json::Value; #[tokio::test] async fn follow_unfollow_user() { let mut h = TestHarness::new().await; // Create two users let user_a = h .signup("follower", "follower@test.com", "password123") .await; h.client.post_form("/logout", "").await; let user_b = h .signup("followee", "followee@test.com", "password123") .await; h.client.post_form("/logout", "").await; // Login as user A h.login("follower", "password123").await; // Follow user B let resp = h .client .post_form(&format!("/api/follow/user/{}", *user_b), "") .await; assert_eq!( resp.status, 200, "Follow user failed: {} {}", resp.status, resp.text ); // Response is HTML (FollowButtonTemplate), check it contains follow state assert!( resp.text.contains('1') || resp.text.contains("follower_count"), "Follow response should contain follower count" ); // Verify in DB let is_following: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2)", ) .bind(user_a) .bind(*user_b) .fetch_one(&h.db) .await .unwrap(); assert!(is_following, "Should be following in DB"); // Unfollow user B let resp = h .client .delete(&format!("/api/follow/user/{}", *user_b)) .await; assert_eq!( resp.status, 200, "Unfollow user failed: {} {}", resp.status, resp.text ); // Verify unfollowed in DB let is_following: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2)", ) .bind(user_a) .bind(*user_b) .fetch_one(&h.db) .await .unwrap(); assert!(!is_following, "Should not be following after unfollow"); } #[tokio::test] async fn follow_unfollow_project() { let mut h = TestHarness::new().await; // Create creator with a public project let creator_id = h .signup("projcreator", "projcreator@test.com", "password123") .await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("projcreator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=followproj&title=Follow+Project") .await; assert_eq!( resp.status, 200, "Create project failed: {} {}", resp.status, resp.text ); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Make it public let resp = h .client .put_json( &format!("/api/projects/{project_id}"), r#"{"visibility": "public"}"#, ) .await; assert_eq!( resp.status, 200, "Make project public failed: {} {}", resp.status, resp.text ); // Logout and create a different user to follow the project h.client.post_form("/logout", "").await; let follower_id = h .signup("projfollower", "projfollower@test.com", "password123") .await; // Follow the project let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert_eq!( resp.status, 200, "Follow project failed: {} {}", resp.status, resp.text ); // Verify in DB let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM follows WHERE follower_id = $1 AND target_id = $2::uuid", ) .bind(follower_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Should have one follow record"); // Unfollow let resp = h .client .delete(&format!("/api/follow/project/{project_id}")) .await; assert_eq!( resp.status, 200, "Unfollow project failed: {} {}", resp.status, resp.text ); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM follows WHERE follower_id = $1 AND target_id = $2::uuid", ) .bind(follower_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Follow record should be deleted"); } #[tokio::test] async fn follow_self_rejected() { let mut h = TestHarness::new().await; let user_id = h .signup("selffollow", "selffollow@test.com", "password123") .await; // Try to follow yourself let resp = h .client .post_form(&format!("/api/follow/user/{}", *user_id), "") .await; assert_eq!( resp.status, 400, "Self-follow should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn own_profile_hides_follow_button() { let mut h = TestHarness::new().await; h.signup("selfview", "selfview@test.com", "password123") .await; let resp = h.client.get("/u/selfview").await; assert_eq!(resp.status, 200); // Should NOT show a follow/unfollow button assert!( !resp.text.contains("follow-btn"), "Own profile should not show follow button" ); } #[tokio::test] async fn library_feed_huge_page_is_clamped_not_overflowing() { // M-UX1: the feed tab's `?page=` must be clamped like every sibling // paginator. A large in-range value (above the 1e9 clamp ceiling) should // return a valid empty page, with the OFFSET bounded by the clamp rather // than running a multi-billion-row deep-scan. let mut h = TestHarness::new().await; h.signup("feedpage", "feedpage@test.com", "password123") .await; let resp = h .client .htmx_get("/library/tabs/feed?page=4000000000") .await; assert_eq!( resp.status, 200, "large page should clamp to a valid empty page, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn follow_nonexistent_rejected() { let mut h = TestHarness::new().await; let _user_id = h .signup("ghostfollow", "ghostfollow@test.com", "password123") .await; // Try to follow a random UUID that doesn't exist let fake_id = uuid::Uuid::new_v4(); let resp = h .client .post_form(&format!("/api/follow/user/{fake_id}"), "") .await; assert_eq!( resp.status, 404, "Following nonexistent user should 404, got {} {}", resp.status, resp.text ); } // Tag follows and the feed // // The consequence, not the control. Following a tag is only worth anything if // it changes what /feed returns, and that path had no coverage at all: the tag // branch of the three UNION-CTE feed queries (db/follows.rs get_followed_items, // get_followed_feed_items, count_followed_feed_items) was never exercised. /// Publish a discoverable item and attach `slug` to it, creating the tag path. /// Returns (item_id, leaf_tag_id). async fn item_tagged_with( h: &mut TestHarness, username: &str, title: &str, slug: &str, ) -> (String, uuid::Uuid) { let setup = h.create_creator_with_item(username, "audio", 1000).await; sqlx::query( "UPDATE items SET title = $1, is_public = true, listed = true, \ scan_status = 'clean', deleted_at = NULL WHERE id = $2::uuid", ) .bind(title) .bind(&setup.item_id) .execute(&h.db) .await .expect("publish item"); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .expect("publish project"); let mut parent: Option = None; let segments: Vec<&str> = slug.split('.').collect(); for depth in 1..=segments.len() { let path = segments[..depth].join("."); let id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \ ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id", ) .bind(segments[depth - 1]) .bind(&path) .bind(parent) .fetch_one(&h.db) .await .expect("upsert tag"); parent = Some(id); } let leaf = parent.expect("leaf tag id"); sqlx::query( "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING", ) .bind(&setup.item_id) .bind(leaf) .execute(&h.db) .await .expect("attach tag"); (setup.item_id, leaf) } /// Following a tag puts its items in the feed, and unfollowing takes them out. #[tokio::test] async fn following_a_tag_changes_the_feed() { let mut h = TestHarness::new().await; let (_item, tag_id) = item_tagged_with( &mut h, "feedcreator", "Tagged Release", "audio.genre.electronic", ) .await; h.client.post_form("/logout", "").await; h.signup("feedfollower", "feedfollower@test.com", "password123") .await; // Before: the tag is not followed, so the feed is empty. let before = h.client.get("/feed").await; assert_eq!(before.status, 200, "{}", before.status); assert!( !before.text.contains("Tagged Release"), "an unfollowed tag must not put items in the feed" ); let follow = h .client .post_form(&format!("/api/follow/tag/{tag_id}"), "") .await; assert_eq!(follow.status, 200, "follow failed: {}", follow.status); // After: the item arrives purely because of the tag. The follower follows // neither the creator nor the project. let after = h.client.get("/feed").await; assert_eq!(after.status, 200, "{}", after.status); assert!( after.text.contains("Tagged Release"), "a followed tag must put its items in the feed; body was: {}", &after.text[..after.text.len().min(600)] ); let unfollow = h.client.delete(&format!("/api/follow/tag/{tag_id}")).await; assert_eq!(unfollow.status, 200, "unfollow failed: {}", unfollow.status); let removed = h.client.get("/feed").await; assert!( !removed.text.contains("Tagged Release"), "unfollowing must take the items back out" ); } /// Resolve a tag id by slug. Ancestors are created by `item_tagged_with` on the /// way down to the leaf, but only the leaf id is returned. async fn tag_id_for(h: &TestHarness, slug: &str) -> uuid::Uuid { sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1") .bind(slug) .fetch_one(&h.db) .await .expect("tag by slug") } /// Following a BRANCH surfaces items tagged with a descendant leaf. /// /// This is the whole point of making following hierarchical, and nothing else /// catches a broken subtree expansion: `item_tags` holds only directly-assigned /// tags, and the write path enforces depth >= 3, so before this change a follow /// on a depth-1 or depth-2 tag matched nothing forever. #[tokio::test] async fn following_a_parent_tag_surfaces_descendant_items() { let mut h = TestHarness::new().await; let (_item, leaf) = item_tagged_with( &mut h, "branchcreator", "Deep Release", "audio.genre.techno", ) .await; let branch = tag_id_for(&h, "audio.genre").await; assert_ne!( branch, leaf, "the followed branch must not be the leaf itself" ); h.client.post_form("/logout", "").await; h.signup("branchfollower", "branchfollower@test.com", "password123") .await; let before = h.client.get("/feed").await; assert!( !before.text.contains("Deep Release"), "an unfollowed branch must not put items in the feed" ); let follow = h .client .post_form(&format!("/api/follow/tag/{branch}"), "") .await; assert_eq!(follow.status, 200, "follow failed: {}", follow.status); let after = h.client.get("/feed").await; assert_eq!(after.status, 200, "{}", after.status); assert!( after.text.contains("Deep Release"), "following audio.genre must surface an item tagged audio.genre.techno; body was: {}", &after.text[..after.text.len().min(600)] ); } /// The same expansion has to hold in `get_followed_items`, which backs the /// signed RSS feed and is a separate query from the feed page's. #[tokio::test] async fn following_a_parent_tag_surfaces_descendant_items_in_rss() { let mut h = TestHarness::new().await; let (_item, _leaf) = item_tagged_with(&mut h, "branchrss", "Deep Syndicated", "audio.genre.dub").await; let branch = tag_id_for(&h, "audio").await; h.client.post_form("/logout", "").await; let follower = h .signup("branchrssfollower", "branchrssf@test.com", "password123") .await; h.client .post_form(&format!("/api/follow/tag/{branch}"), "") .await; let url = makenotwork::crypto::generate_feed_url( "", follower, 0, "test-signing-secret-for-integration-tests", ); let resp = h.client.get(&url).await; assert_eq!(resp.status, 200, "RSS feed: {} {}", resp.status, url); assert!( resp.text.contains("Deep Syndicated"), "following the depth-1 root must reach a depth-3 leaf's item over RSS" ); } /// A followed tag reaches the signed RSS feed too. /// /// The RSS handler uses get_followed_items, a different query from the /feed /// page's get_followed_feed_items, so the tag branch has to hold in both. Its /// doc comment says "followed users and projects", which undersells it. #[tokio::test] async fn following_a_tag_changes_the_rss_feed() { let mut h = TestHarness::new().await; let (_item, tag_id) = item_tagged_with( &mut h, "rsscreator", "Syndicated Release", "audio.genre.ambient", ) .await; h.client.post_form("/logout", "").await; let follower = h .signup("rssfollower", "rssfollower@test.com", "password123") .await; h.client .post_form(&format!("/api/follow/tag/{tag_id}"), "") .await; // The feed URL is HMAC-signed so readers can fetch without cookies; mint one // with the harness's known secret rather than scraping it out of a page. let url = makenotwork::crypto::generate_feed_url( "", follower, 0, "test-signing-secret-for-integration-tests", ); let resp = h.client.get(&url).await; assert_eq!(resp.status, 200, "RSS feed: {} {}", resp.status, url); assert!( resp.text.contains("Syndicated Release"), "a followed tag must reach the RSS feed as well as the feed page" ); }