//! 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}" ); } } /// A method mismatch on a path that exists renders the branded error page. /// /// GET /logout is the case that showed up in the wild: axum answered with a /// bodiless 405, and the browser replaced it with its own network-error screen. /// The route itself stays POST-plus-CSRF, so the GET must not log anyone out. #[tokio::test] async fn method_mismatch_renders_error_page() { let mut h = TestHarness::new().await; let user_id = h .signup("methodmix", "methodmix@example.com", "password123") .await; assert!(!user_id.is_nil()); let resp = h.client.get("/logout").await; assert_eq!(resp.status, 405, "GET /logout should be 405"); assert!( resp.text.contains("error-page"), "405 should render the error template, got: {}", resp.text ); assert!( resp.text.contains("405"), "error page should name the status, got: {}", resp.text ); // Still logged in: the 405 must not have performed the logout. let resp = h.client.get("/dashboard").await; assert_eq!(resp.status, 200, "GET /logout must not end the session"); // The same holds for any other method mismatch. let resp = h.client.post_form("/login", "").await; assert_ne!(resp.status, 405, "/login accepts POST"); let resp = h.client.delete("/pricing").await; assert_eq!(resp.status, 405, "DELETE /pricing should be 405"); assert!( resp.text.contains("error-page"), "405 should render the error template, got: {}", resp.text ); } /// A 401 page offers the actions that resolve it, not only "Go Home". /// /// Anonymous GET /feed was a dead end: the one thing that fixes a 401 is /// logging in, and the error page linked nowhere near it. #[tokio::test] async fn unauthorized_page_offers_login_and_signup() { let mut h = TestHarness::new().await; let resp = h.client.get("/feed").await; assert_eq!(resp.status, 401, "anonymous /feed should be 401"); assert!( resp.text.contains("href=\"/login\""), "401 page should link to login, got: {}", resp.text ); assert!( resp.text.contains("href=\"/join\""), "401 page should link to signup, got: {}", resp.text ); assert!( resp.text.contains("Go Home"), "401 page should keep Go Home, got: {}", resp.text ); // The auth actions are gated on the status: a 404 keeps the single action. let resp = h.client.get("/definitely-not-a-page").await; assert_eq!(resp.status, 404); assert!( !resp.text.contains("Sign up"), "404 page should not offer the auth actions, got: {}", resp.text ); } /// The Askama Forums and Communities tabs fail soft on a transport failure. /// /// Both handlers mapped a `send()` error to a 500, and a timeout is a `send()` /// error, so the five-second ceiling that exists to bound a slow Multithreaded /// was itself what broke the tab: the 2026-08-14 load sweep saw 300 of 300 /// requests answer 500 at exactly that latency. An unreachable address takes /// the same branch. #[tokio::test] async fn forum_tabs_fail_soft_when_multithreaded_is_unreachable() { let mut h = crate::harness::TestHarness::build(crate::harness::BuildOptions { // Nothing listens here, so the request fails at the transport. mt_base_url: Some("http://127.0.0.1:9".to_owned()), ..Default::default() }) .await; h.signup("mtdown", "mtdown@example.com", "password123") .await; let resp = h.client.htmx_get("/dashboard/tabs/forums").await; assert_eq!( resp.status, 200, "forums tab should render the empty state, got: {}", resp.text ); let resp = h.client.htmx_get("/library/tabs/communities").await; assert_eq!( resp.status, 200, "communities tab should render the empty state, got: {}", resp.text ); }