//! Mailing list infrastructure: default list creation, follow/unfollow subscription hooks, //! unsubscribe asymmetry, idempotency, and content newsletter delivery (I4). use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator, create a project via API, return (creator_id, project_id). async fn create_project(h: &mut TestHarness) -> (makenotwork::db::UserId, String) { let creator_id = h.signup("mlcreator", "mlcreator@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("mlcreator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=mlproject&title=ML+Project") .await; assert!(resp.status.is_success(), "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!(resp.status.is_success(), "Make project public failed: {} {}", resp.status, resp.text); (creator_id, project_id) } #[tokio::test] async fn project_creation_creates_default_lists() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Verify two mailing lists were created (content + devlog) let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid", ) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 2, "Should have 2 default mailing lists (content + devlog)"); // Verify list types let types: Vec = sqlx::query_scalar( "SELECT list_type FROM mailing_lists WHERE project_id = $1::uuid ORDER BY list_type", ) .bind(&project_id) .fetch_all(&h.db) .await .unwrap(); assert_eq!(types, vec!["content", "devlog"]); } #[tokio::test] async fn follow_project_subscribes_to_content_list() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Create a follower h.client.post_form("/logout", "").await; let follower_id = h.signup("mlfollower", "mlfollower@test.com", "password123").await; // Follow the project let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success(), "Follow failed: {} {}", resp.status, resp.text); // Verify subscriber row exists in content list let sub_count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mailing_list_subscribers s JOIN mailing_lists l ON l.id = s.list_id WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2 "#, ) .bind(&project_id) .bind(follower_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sub_count, 1, "Follower should be subscribed to content list"); } #[tokio::test] async fn unfollow_project_removes_from_all_lists() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Create and login as follower h.client.post_form("/logout", "").await; let follower_id = h.signup("mlfollower2", "mlfollower2@test.com", "password123").await; // Follow (auto-subscribes to content list) let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success()); // Also manually subscribe to devlog list let devlog_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'devlog'", ) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); sqlx::query("INSERT INTO mailing_list_subscribers (list_id, user_id) VALUES ($1, $2)") .bind(devlog_id) .bind(follower_id) .execute(&h.db) .await .unwrap(); // Verify 2 subscriptions exist let sub_count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mailing_list_subscribers s JOIN mailing_lists l ON l.id = s.list_id WHERE l.project_id = $1::uuid AND s.user_id = $2 "#, ) .bind(&project_id) .bind(follower_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sub_count, 2, "Should be subscribed to both lists before unfollow"); // Unfollow the project let resp = h .client .delete(&format!("/api/follow/project/{project_id}")) .await; assert!(resp.status.is_success(), "Unfollow failed: {} {}", resp.status, resp.text); // Verify all subscriptions removed let sub_count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mailing_list_subscribers s JOIN mailing_lists l ON l.id = s.list_id WHERE l.project_id = $1::uuid AND s.user_id = $2 "#, ) .bind(&project_id) .bind(follower_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sub_count, 0, "All subscriptions should be removed after unfollow"); } #[tokio::test] async fn unsubscribe_removes_from_list_but_keeps_follow() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Create and login as follower h.client.post_form("/logout", "").await; let follower_id = h.signup("mlfollower3", "mlfollower3@test.com", "password123").await; // Follow (auto-subscribes to content list) let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success()); // Directly unsubscribe from content list via DB let content_list_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'content'", ) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); let deleted = sqlx::query( "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2", ) .bind(content_list_id) .bind(follower_id) .execute(&h.db) .await .unwrap(); assert_eq!(deleted.rows_affected(), 1, "Should have deleted one subscription"); // Verify follow relationship still exists let is_following: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2::uuid)", ) .bind(follower_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert!(is_following, "Follow should still exist after mailing list unsubscribe"); } #[tokio::test] async fn subscribe_idempotent() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Create and login as follower h.client.post_form("/logout", "").await; let follower_id = h.signup("mlfollower4", "mlfollower4@test.com", "password123").await; // Follow twice (both should succeed without error) let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success()); let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success()); // Verify exactly one subscriber row let sub_count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mailing_list_subscribers s JOIN mailing_lists l ON l.id = s.list_id WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2 "#, ) .bind(&project_id) .bind(follower_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sub_count, 1, "Should have exactly one subscriber row despite double follow"); } #[tokio::test] async fn default_lists_idempotent() { let mut h = TestHarness::new().await; let (_creator_id, project_id) = create_project(&mut h).await; // Insert duplicate lists via SQL with ON CONFLICT — should not error sqlx::query( r#" INSERT INTO mailing_lists (project_id, list_type, name) VALUES ($1::uuid, 'content', 'Duplicate Content') ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name "#, ) .bind(&project_id) .execute(&h.db) .await .expect("ON CONFLICT should handle duplicate list creation"); // Still exactly 2 lists (content was upserted, devlog unchanged) let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid", ) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 2, "Should still have exactly 2 lists after idempotent insert"); } // ============================================================================= // I4: Content Newsletter Delivery // ============================================================================= /// Helper: create a project with a follower subscribed to the content mailing list. /// Returns (creator_id, project_id, follower_id). async fn setup_project_with_subscriber( h: &mut TestHarness, ) -> (makenotwork::db::UserId, String, makenotwork::db::UserId) { let creator_id = h.signup("i4creator", "i4creator@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("i4creator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=i4project&title=I4+Project") .await; assert!(resp.status.is_success(), "Create project failed: {} {}", resp.status, resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Make project public let resp = h .client .put_json( &format!("/api/projects/{project_id}"), r#"{"visibility": "public"}"#, ) .await; assert!(resp.status.is_success()); // Create a follower and follow the project (auto-subscribes to content list) h.client.post_form("/logout", "").await; let follower_id = h.signup("i4follower", "i4follower@test.com", "password123").await; // Verify email so they'd receive announcements sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(follower_id) .execute(&h.db) .await .unwrap(); let resp = h .client .post_form(&format!("/api/follow/project/{project_id}"), "") .await; assert!(resp.status.is_success(), "Follow failed: {} {}", resp.status, resp.text); (creator_id, project_id, follower_id) } #[tokio::test] async fn item_release_emails_use_mailing_list() { let mut h = TestHarness::new().await; let (_creator_id, project_id, follower_id) = setup_project_with_subscriber(&mut h).await; // Switch back to creator h.client.post_form("/logout", "").await; h.login("i4creator", "password123").await; // Create an item let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Test+Item&item_type=digital", ) .await; assert!(resp.status.is_success(), "Create item failed: {} {}", resp.status, resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Publish the item let resp = h .client .put_form( &format!("/api/items/{item_id}"), "is_public=true", ) .await; assert!(resp.status.is_success(), "Publish item failed: {} {}", resp.status, resp.text); // Verify release_announced_at is set let announced: bool = sqlx::query_scalar( "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert!(announced, "release_announced_at should be set after publishing"); // Verify the follower is a subscriber on the content mailing list let sub_count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM mailing_list_subscribers s JOIN mailing_lists l ON l.id = s.list_id WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2 "#, ) .bind(&project_id) .bind(follower_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sub_count, 1, "Follower should be on content mailing list"); // Verify idempotency: trying to clear release_announced_at and re-announce // should demonstrate that the column is set (no row affected) let rows_affected = sqlx::query( "UPDATE items SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL", ) .bind(&item_id) .execute(&h.db) .await .unwrap() .rows_affected(); assert_eq!(rows_affected, 0, "Second mark should be a no-op (already announced)"); } #[tokio::test] async fn blog_post_announcement_sends_emails() { let mut h = TestHarness::new().await; let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await; // Switch back to creator h.client.post_form("/logout", "").await; h.login("i4creator", "password123").await; // Create a published blog post let resp = h .client .post_json( &format!("/api/projects/{project_id}/blog"), r#"{"title": "Test Blog Post", "body_markdown": "Hello world", "is_published": true}"#, ) .await; assert!(resp.status.is_success(), "Create blog post failed: {} {}", resp.status, resp.text); let post: Value = resp.json(); let post_id = post["id"].as_str().unwrap().to_string(); // Verify release_announced_at is set on the blog post let announced: bool = sqlx::query_scalar( "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid", ) .bind(&post_id) .fetch_one(&h.db) .await .unwrap(); assert!(announced, "release_announced_at should be set after publishing blog post"); // Verify idempotency: second mark is a no-op let rows_affected = sqlx::query( "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL", ) .bind(&post_id) .execute(&h.db) .await .unwrap() .rows_affected(); assert_eq!(rows_affected, 0, "Second mark should be a no-op (already announced)"); } #[tokio::test] async fn web_only_item_skips_email() { let mut h = TestHarness::new().await; let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await; // Switch back to creator h.client.post_form("/logout", "").await; h.login("i4creator", "password123").await; // Create an item let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=WebOnly+Item&item_type=digital", ) .await; assert!(resp.status.is_success()); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Set web_only=true and publish let resp = h .client .put_form( &format!("/api/items/{item_id}"), "is_public=true&web_only=true", ) .await; assert!(resp.status.is_success(), "Publish web_only item failed: {} {}", resp.status, resp.text); // Verify web_only is set let web_only: bool = sqlx::query_scalar( "SELECT web_only FROM items WHERE id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert!(web_only, "web_only should be true"); // Verify release_announced_at IS set (the mark happens before the web_only check) let announced: bool = sqlx::query_scalar( "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert!(announced, "release_announced_at should still be set (idempotent guard)"); } #[tokio::test] async fn web_only_blog_post_skips_email() { let mut h = TestHarness::new().await; let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await; // Switch back to creator h.client.post_form("/logout", "").await; h.login("i4creator", "password123").await; // Create a published web_only blog post let resp = h .client .post_json( &format!("/api/projects/{project_id}/blog"), r#"{"title": "Web Only Post", "body_markdown": "Silent post", "is_published": true, "web_only": true}"#, ) .await; assert!(resp.status.is_success(), "Create web_only blog post failed: {} {}", resp.status, resp.text); let post: Value = resp.json(); let post_id = post["id"].as_str().unwrap().to_string(); // Verify web_only is set let web_only: bool = sqlx::query_scalar( "SELECT web_only FROM blog_posts WHERE id = $1::uuid", ) .bind(&post_id) .fetch_one(&h.db) .await .unwrap(); assert!(web_only, "web_only should be true on blog post"); // Verify release_announced_at IS set (idempotent guard still fires) let announced: bool = sqlx::query_scalar( "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid", ) .bind(&post_id) .fetch_one(&h.db) .await .unwrap(); assert!(announced, "release_announced_at should still be set for web_only post"); }