//! 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("`: the element held both prices in the /// DOM and rewrote the radio's value, so the browser knew a price the server /// also knew. Asking the recompute at each mode has to give two answers, or the /// question is decoration. #[tokio::test] async fn the_price_mode_changes_what_the_recompute_computes_on() { let mut h = TestHarness::with_founder_window_open().await; // Through htmx, because a bare GET of a fragment endpoint is a navigation // and `fragment_redirect` bounces it to the page. See `crate::fragment_redirect`. let founder = h .client .htmx_get("/pricing/compare?tier=basic&price_mode=founder") .await; let list = h .client .htmx_get("/pricing/compare?tier=basic&price_mode=list") .await; assert_eq!(founder.status, 200); assert_eq!(list.status, 200); assert_ne!( founder.text, list.text, "both modes computed the same take-home, so the mode is not reaching \ the calculator" ); } /// A calculator URL written before the tier radio sent a name still lands on /// the scenario it named. #[tokio::test] async fn a_shared_link_carrying_a_tier_price_still_computes_on_it() { let mut h = TestHarness::new().await; let resp = h .client .htmx_get("/pricing/compare?tier=16&sales=100") .await; assert_eq!(resp.status, 200); assert!(!resp.text.is_empty(), "the recompute answered nothing"); } // ── Fan+ benefit claims ── /// The landing card and /fan-plus must name the same benefits, and only ones /// the code grants. /// /// Two pages stating a benefit set by hand drift, and a claim survives that the /// credit issuance path here and the perk checks in multithreaded do not grant. /// 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"); // `/creators` is a route axum still owns, so this is what axum's own method // fallback renders and is what this test is about. let resp = h.client.delete("/creators").await; assert_eq!(resp.status, 405, "DELETE /creators should be 405"); assert!( resp.text.contains("error-page"), "405 should render the error template, got: {}", resp.text ); // And `/pricing` again, which stood here until `1e35bc8a` made it a // described screen and the description layer answered 404: the path existed // and only the verb did not, which is what quasicoherent `370c0849` split // apart. It answers 405 with `Allow` since quasi 0.80. // // No error template here, and that is the difference worth keeping in one // test rather than two: the described screen refuses inside quasi and never // reaches axum's fallback, so what comes back is the description layer's // own notice. Both are 405; only one is this server's page. let resp = h.client.delete("/pricing").await; assert_eq!(resp.status, 405, "DELETE /pricing should be 405"); assert_eq!( resp.headers.get("allow").and_then(|v| v.to_str().ok()), Some("GET"), "and it names the verb the address does take" ); } /// 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. /// /// A timeout is a `send()` error, so a handler mapping `send()` errors to a 500 /// turns the five-second ceiling that exists to bound a slow Multithreaded into /// the thing that breaks the tab. 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 ); }