//! Page route tests: public pages, user/project/item detail, RSS feeds, discover. //! //! These test that page routes render successfully and contain expected content. //! Complements the API workflow tests which focus on JSON endpoints. use crate::harness::TestHarness; use serde_json::Value; #[tokio::test] async fn public_pages_render() { let mut h = TestHarness::new().await; // Landing page let resp = h.client.get("/").await; assert_eq!(resp.status, 200, "Landing page should return 200"); // Login page let resp = h.client.get("/login").await; assert_eq!(resp.status, 200, "Login page should return 200"); // Join page let resp = h.client.get("/join").await; assert_eq!(resp.status, 200, "Join page should return 200"); // Pricing page let resp = h.client.get("/pricing").await; assert_eq!(resp.status, 200, "Pricing page should return 200"); // Discover page let resp = h.client.get("/discover").await; assert_eq!(resp.status, 200, "Discover page should return 200"); // Discover with filters let resp = h.client.get("/discover?mode=projects").await; assert_eq!(resp.status, 200, "Discover projects mode should return 200"); // Tag tree browser let resp = h.client.get("/discover/tags").await; assert_eq!(resp.status, 200, "Tag tree should return 200"); } #[tokio::test] async fn user_and_project_pages() { let mut h = TestHarness::new().await; // Setup: creator with public project and item let user_id = h .signup("pagetest", "pagetest@example.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("pagetest", "password123").await; let resp = h .client .post_form("/api/projects", "slug=test-proj&title=Test+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); // Make project public h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; // Create a text item and make it public let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Test+Article&item_type=text&is_public=true&price_cents=0", ) .await; assert_eq!(resp.status, 200, "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Add text content. The store page renders an excerpt from the first // paragraph (full body lives on /l/{id}), so keep the asserted phrase // in paragraph one. h.client .put_json( &format!("/api/items/{item_id}/text"), "{\"body\": \"Test content for page rendering.\\n\\n# Hello\\n\\nMore body below.\"}", ) .await; // Log out to test as anonymous user h.client.post_form("/logout", "").await; // ── User profile page ── let resp = h.client.get("/u/pagetest").await; assert_eq!(resp.status, 200, "User page should return 200"); assert!( resp.text.contains("Test Project"), "User page should show project title" ); // ── Project page ── let resp = h.client.get("/p/test-proj").await; assert_eq!(resp.status, 200, "Project page should return 200"); assert!( resp.text.contains("Test Article"), "Project page should show item title" ); // ── Item page (text reader) ── let resp = h.client.get(&format!("/i/{item_id}")).await; assert_eq!(resp.status, 200, "Item page should return 200"); assert!( resp.text.contains("Test content"), "Item page should render text content" ); // ── 404 for nonexistent user/project ── let resp = h.client.get("/u/nonexistent-user-xyz").await; assert_eq!(resp.status, 404, "Nonexistent user should return 404"); let resp = h.client.get("/p/nonexistent-project-xyz").await; assert_eq!(resp.status, 404, "Nonexistent project should return 404"); } #[tokio::test] async fn rss_feeds() { let mut h = TestHarness::new().await; // Setup: creator with public project and item let user_id = h .signup("rsstest", "rsstest@example.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("rsstest", "password123").await; let resp = h .client .post_form("/api/projects", "slug=rss-proj&title=RSS+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); // Make project public h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; // Create a public item let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=RSS+Item&item_type=text&is_public=true&price_cents=0", ) .await; assert_eq!(resp.status, 200, "Create item failed: {}", resp.text); h.client.post_form("/logout", "").await; // ── Creator RSS feed ── let resp = h.client.get("/u/rsstest/rss").await; assert_eq!(resp.status, 200, "Creator RSS feed should return 200"); assert!( resp.text.contains(" can flip without a round trip. assert!( resp.text .contains(&format!(r#"data-price-std="{}""#, prices.basic_std)), "list price missing from the DOM, the toggle would have to fetch it" ); } // ── Fan+ benefit claims ── /// The landing card and /fan-plus must name the same benefits, and only ones /// the code grants. /// /// They had drifted: the landing page claimed "Early access" on testnot.work /// and both claimed "Platform polls" and "Dev community access". Audited /// 2026-08-05 against the credit issuance path here and the perk checks in /// multithreaded, none of those three was a thing Fan+ unlocks. Two pages /// stating a benefit set by hand is how that happened, so this pins them /// together rather than trusting the next edit to touch both. #[tokio::test] async fn fan_plus_benefits_match_across_both_pages() { let mut h = TestHarness::new().await; let landing = h.client.get("/").await; let fan_plus = h.client.get("/fan-plus").await; assert_eq!(landing.status, 200); assert_eq!(fan_plus.status, 200); // Each entitlement, with the code that grants it: // $5 monthly credit -> db::promo_codes::issue_fan_plus_credit_code // + badge -> mt thread.html, gated on author_is_fan_plus // Forum signatures -> mt account.rs, gated on UserPerks::effective_plus // Image embeds -> mt reject_embeds_for_free_user for claim in [ "$5 monthly credit", "+ badge", "Forum signatures", "Image embeds", ] { assert!( landing.text.contains(claim), "landing page dropped the {claim} benefit" ); assert!( fan_plus.text.contains(claim), "/fan-plus dropped the {claim} benefit" ); } // Retired claims, none of which named a real entitlement. for gone in ["Platform polls", "Dev community access", "Early access"] { assert!( !landing.text.contains(gone), "landing page still claims {gone}" ); assert!( !fan_plus.text.contains(gone), "/fan-plus still claims {gone}" ); } } // ── Landing notify-me capture ── /// The form works with JS off. /// /// It had no action and no method, so a browser without JS submitted a GET to /// `/` and the address was dropped silently. This is the only email capture on /// the landing page, so a silent loss is a lost signup nobody can count. #[tokio::test] async fn notify_form_without_js_stores_the_address() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; let resp = h .client .post_form("/notify", "email=nojs@example.com") .await; assert_eq!( resp.status, 303, "expected a redirect back to the landing page" ); let location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .unwrap_or_default(); assert!( location.starts_with("/?notify=ok"), "redirected to {location}" ); let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") .bind("nojs@example.com") .fetch_one(&h.db) .await .expect("count signups"); assert_eq!(stored, 1, "the address never reached storage"); } /// A rejected address comes back as a sentence on the page, not a 422. This is /// the last thing on the landing page and an error code is a worse outcome /// than being told the address looked wrong. #[tokio::test] async fn notify_form_rejects_a_bad_address_without_erroring() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; let resp = h.client.post_form("/notify", "email=not-an-address").await; assert_eq!(resp.status, 303); assert!( resp.headers .get("location") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .starts_with("/?notify=invalid") ); let landing = h.client.get("/?notify=invalid").await; assert!( landing.text.contains("That address didn't look right."), "the landing page said nothing about the rejection" ); } /// The success message renders for a no-JS visitor coming back off the /// redirect, and not on a plain page load. #[tokio::test] async fn notify_status_renders_only_when_the_redirect_says_so() { let mut h = TestHarness::new().await; let plain = h.client.get("/").await; assert!( !plain.text.contains("You're on the list."), "status shown on a plain load" ); let after = h.client.get("/?notify=ok").await; assert!(after.text.contains("You're on the list.")); } // ── Landing signup unsubscribe ── // // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and // count, and no way out. Withdrawal has to be as easy as consent, and consent // is one form field. fn signup_unsub_url(email: &str) -> String { makenotwork::email::generate_signup_unsubscribe_url( "", email, "test-signing-secret-for-integration-tests", ) } /// The signed link unsubscribes, and the address stops being mailable. #[tokio::test] async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=leaving@example.com") .await; let before: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(before, 1, "signup did not land"); let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await; assert_eq!(resp.status, 200); let after: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(after, 0, "still mailable after unsubscribing"); // Marked, not deleted: a deleted address is one the next import re-adds. let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") .bind("leaving@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(retained, 1, "the opt-out record was thrown away"); } /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step, /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo. /// Retries must not fail, so the second POST answers the same as the first. #[tokio::test] async fn signup_unsubscribe_one_click_post_is_idempotent() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=oneclick@example.com") .await; let url = signup_unsub_url("oneclick@example.com"); let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(first.status, 200); let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; assert_eq!(second.status, 200, "a retried one-click POST must not fail"); } /// A tampered signature does nothing. The address is signed over, so a token /// for one address cannot unsubscribe another. #[tokio::test] async fn signup_unsubscribe_rejects_a_forged_link() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=victim@example.com") .await; // Take a valid token for one address and point it at another. let forged = signup_unsub_url("attacker@example.com") .replace("attacker%40example.com", "victim%40example.com"); let resp = h.client.get(&forged).await; assert!( resp.text.contains("Invalid Link"), "a forged link was accepted" ); let still_mailable: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("victim@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(still_mailable, 1, "a forged link unsubscribed someone"); } /// Signing up again after unsubscribing re-subscribes. The form is the only /// interface, so refusing would leave someone unable to opt back in. #[tokio::test] async fn signing_up_again_after_unsubscribing_restores_the_subscription() { let mut h = TestHarness::new().await; h.client.fetch_csrf_token().await; h.client .post_form("/notify", "email=returning@example.com") .await; h.client .get(&signup_unsub_url("returning@example.com")) .await; h.client .post_form("/notify", "email=returning@example.com") .await; let mailable: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", ) .bind("returning@example.com") .fetch_one(&h.db) .await .expect("count"); assert_eq!(mailable, 1, "could not opt back in"); }