Skip to main content

max / makenotwork

21.5 KB · 649 lines History Blame Raw
1 //! Page route tests: public pages, user/project/item detail, RSS feeds, discover.
2 //!
3 //! These test that page routes render successfully and contain expected content.
4 //! Complements the API workflow tests which focus on JSON endpoints.
5
6 use crate::harness::TestHarness;
7 use serde_json::Value;
8
9 #[tokio::test]
10 async fn public_pages_render() {
11 let mut h = TestHarness::new().await;
12
13 // Landing page
14 let resp = h.client.get("/").await;
15 assert_eq!(resp.status, 200, "Landing page should return 200");
16
17 // Login page
18 let resp = h.client.get("/login").await;
19 assert_eq!(resp.status, 200, "Login page should return 200");
20
21 // Join page
22 let resp = h.client.get("/join").await;
23 assert_eq!(resp.status, 200, "Join page should return 200");
24
25 // Pricing page
26 let resp = h.client.get("/pricing").await;
27 assert_eq!(resp.status, 200, "Pricing page should return 200");
28
29 // Discover page
30 let resp = h.client.get("/discover").await;
31 assert_eq!(resp.status, 200, "Discover page should return 200");
32
33 // Discover with filters
34 let resp = h.client.get("/discover?mode=projects").await;
35 assert_eq!(resp.status, 200, "Discover projects mode should return 200");
36
37 // Tag tree browser
38 let resp = h.client.get("/discover/tags").await;
39 assert_eq!(resp.status, 200, "Tag tree should return 200");
40 }
41
42 #[tokio::test]
43 async fn user_and_project_pages() {
44 let mut h = TestHarness::new().await;
45
46 // Setup: creator with public project and item
47 let user_id = h
48 .signup("pagetest", "pagetest@example.com", "password123")
49 .await;
50 h.grant_creator(user_id).await;
51 h.client.post_form("/logout", "").await;
52 h.login("pagetest", "password123").await;
53
54 let resp = h
55 .client
56 .post_form("/api/projects", "slug=test-proj&title=Test+Project")
57 .await;
58 let project: Value = resp.json();
59 let project_id = project["id"].as_str().unwrap();
60
61 // Make project public
62 h.client
63 .put_json(
64 &format!("/api/projects/{project_id}"),
65 r#"{"is_public": true}"#,
66 )
67 .await;
68
69 // Create a text item and make it public
70 let resp = h
71 .client
72 .post_form(
73 &format!("/api/projects/{project_id}/items"),
74 "title=Test+Article&item_type=text&is_public=true&price_cents=0",
75 )
76 .await;
77 assert_eq!(resp.status, 200, "Create item failed: {}", resp.text);
78 let item: Value = resp.json();
79 let item_id = item["id"].as_str().unwrap();
80
81 // Add text content. The store page renders an excerpt from the first
82 // paragraph (full body lives on /l/{id}), so keep the asserted phrase
83 // in paragraph one.
84 h.client
85 .put_json(
86 &format!("/api/items/{item_id}/text"),
87 "{\"body\": \"Test content for page rendering.\\n\\n# Hello\\n\\nMore body below.\"}",
88 )
89 .await;
90
91 // Log out to test as anonymous user
92 h.client.post_form("/logout", "").await;
93
94 // ── User profile page ──
95 let resp = h.client.get("/u/pagetest").await;
96 assert_eq!(resp.status, 200, "User page should return 200");
97 assert!(
98 resp.text.contains("Test Project"),
99 "User page should show project title"
100 );
101
102 // ── Project page ──
103 let resp = h.client.get("/p/test-proj").await;
104 assert_eq!(resp.status, 200, "Project page should return 200");
105 assert!(
106 resp.text.contains("Test Article"),
107 "Project page should show item title"
108 );
109
110 // ── Item page (text reader) ──
111 let resp = h.client.get(&format!("/i/{item_id}")).await;
112 assert_eq!(resp.status, 200, "Item page should return 200");
113 assert!(
114 resp.text.contains("Test content"),
115 "Item page should render text content"
116 );
117
118 // ── 404 for nonexistent user/project ──
119 let resp = h.client.get("/u/nonexistent-user-xyz").await;
120 assert_eq!(resp.status, 404, "Nonexistent user should return 404");
121
122 let resp = h.client.get("/p/nonexistent-project-xyz").await;
123 assert_eq!(resp.status, 404, "Nonexistent project should return 404");
124 }
125
126 #[tokio::test]
127 async fn rss_feeds() {
128 let mut h = TestHarness::new().await;
129
130 // Setup: creator with public project and item
131 let user_id = h
132 .signup("rsstest", "rsstest@example.com", "password123")
133 .await;
134 h.grant_creator(user_id).await;
135 h.client.post_form("/logout", "").await;
136 h.login("rsstest", "password123").await;
137
138 let resp = h
139 .client
140 .post_form("/api/projects", "slug=rss-proj&title=RSS+Project")
141 .await;
142 let project: Value = resp.json();
143 let project_id = project["id"].as_str().unwrap();
144
145 // Make project public
146 h.client
147 .put_json(
148 &format!("/api/projects/{project_id}"),
149 r#"{"is_public": true}"#,
150 )
151 .await;
152
153 // Create a public item
154 let resp = h
155 .client
156 .post_form(
157 &format!("/api/projects/{project_id}/items"),
158 "title=RSS+Item&item_type=text&is_public=true&price_cents=0",
159 )
160 .await;
161 assert_eq!(resp.status, 200, "Create item failed: {}", resp.text);
162
163 h.client.post_form("/logout", "").await;
164
165 // ── Creator RSS feed ──
166 let resp = h.client.get("/u/rsstest/rss").await;
167 assert_eq!(resp.status, 200, "Creator RSS feed should return 200");
168 assert!(
169 resp.text.contains("<rss") || resp.text.contains("<?xml"),
170 "Creator feed should be valid XML"
171 );
172 assert!(
173 resp.text.contains("RSS Item"),
174 "Creator feed should contain item title"
175 );
176
177 // ── Project RSS feed ──
178 let resp = h.client.get("/p/rss-proj/rss").await;
179 assert_eq!(resp.status, 200, "Project RSS feed should return 200");
180 assert!(
181 resp.text.contains("RSS Item"),
182 "Project feed should contain item title"
183 );
184 }
185
186 #[tokio::test]
187 async fn unpublished_item_visibility() {
188 let mut h = TestHarness::new().await;
189
190 // Setup: creator with project
191 let user_id = h
192 .signup("vistest", "vistest@example.com", "password123")
193 .await;
194 h.grant_creator(user_id).await;
195 h.client.post_form("/logout", "").await;
196 h.login("vistest", "password123").await;
197
198 let resp = h
199 .client
200 .post_form("/api/projects", "slug=vis-proj&title=Vis+Project")
201 .await;
202 let project: Value = resp.json();
203 let project_id = project["id"].as_str().unwrap();
204
205 // Create an item (defaults to public) then make it private
206 let resp = h
207 .client
208 .post_form(
209 &format!("/api/projects/{project_id}/items"),
210 "title=Private+Item&item_type=text",
211 )
212 .await;
213 let item: Value = resp.json();
214 let item_id = item["id"].as_str().unwrap();
215
216 // Make it non-public
217 h.client
218 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
219 .await;
220
221 // Owner can see it
222 let resp = h.client.get(&format!("/i/{item_id}")).await;
223 assert_eq!(resp.status, 200, "Owner should see unpublished item");
224
225 // Log out, anonymous user should get 404
226 h.client.post_form("/logout", "").await;
227
228 let resp = h.client.get(&format!("/i/{item_id}")).await;
229 assert_eq!(
230 resp.status, 404,
231 "Anonymous user should not see unpublished item"
232 );
233
234 // Different user should also get 404
235 h.signup("other", "other@example.com", "password123").await;
236 h.client.post_form("/logout", "").await;
237 h.login("other", "password123").await;
238
239 let resp = h.client.get(&format!("/i/{item_id}")).await;
240 assert_eq!(
241 resp.status, 404,
242 "Non-owner should not see unpublished item"
243 );
244 }
245
246 #[tokio::test]
247 async fn discover_htmx_partial() {
248 let mut h = TestHarness::new().await;
249
250 let resp = h.client.htmx_get("/discover/results").await;
251 assert_eq!(
252 resp.status, 200,
253 "Discover results partial should return 200"
254 );
255
256 let resp = h.client.htmx_get("/discover/results?mode=projects").await;
257 assert_eq!(
258 resp.status, 200,
259 "Discover results projects mode should return 200"
260 );
261
262 let resp = h
263 .client
264 .htmx_get("/discover/results?item_type=audio&sort=newest")
265 .await;
266 assert_eq!(
267 resp.status, 200,
268 "Discover results with filters should return 200"
269 );
270 }
271
272 /// A malformed query param is the likeliest way a visitor meets an error page:
273 /// they edit a URL, or follow a stale link. Axum's own rejection is unlayouted
274 /// `text/plain`, which shows the framework rather than the product (g2-06).
275 #[tokio::test]
276 async fn malformed_query_params_render_the_branded_error_page() {
277 let mut h = TestHarness::new().await;
278
279 for path in ["/discover?page=abc", "/discover?min_price=abc"] {
280 let resp = h.client.get(path).await;
281 assert_eq!(resp.status, 400, "{path}");
282 assert!(
283 resp.headers
284 .get("content-type")
285 .and_then(|v| v.to_str().ok())
286 .is_some_and(|v| v.starts_with("text/html")),
287 "{path} answered with a bare rejection, not the error page"
288 );
289 }
290 }
291
292 /// A paywalled project page tells a visitor how much is behind the paywall.
293 ///
294 /// Hiding the count sells the project worse than showing it: a lot of items is
295 /// the argument for paying. The items themselves stay gated, so this leaks
296 /// nothing that was being sold.
297 #[tokio::test]
298 async fn paywalled_project_page_shows_its_item_count() {
299 let mut h = TestHarness::new().await;
300
301 let user_id = h
302 .signup("paywalltest", "paywalltest@example.com", "password123")
303 .await;
304 h.grant_creator(user_id).await;
305 h.client.post_form("/logout", "").await;
306 h.login("paywalltest", "password123").await;
307
308 let resp = h
309 .client
310 .post_form("/api/projects", "slug=paywall-proj&title=Paywall+Project")
311 .await;
312 let project: Value = resp.json();
313 let project_id = project["id"].as_str().unwrap();
314
315 h.client
316 .put_json(
317 &format!("/api/projects/{project_id}"),
318 r#"{"is_public": true}"#,
319 )
320 .await;
321
322 for title in ["First+Piece", "Second+Piece"] {
323 let resp = h
324 .client
325 .post_form(
326 &format!("/api/projects/{project_id}/items"),
327 &format!("title={title}&item_type=text&is_public=true&price_cents=0"),
328 )
329 .await;
330 assert_eq!(resp.status, 200, "create item failed: {}", resp.text);
331 }
332
333 // Price the project itself, which is what puts the page behind the paywall.
334 let resp = h
335 .client
336 .put_json(
337 &format!("/api/projects/{project_id}"),
338 r#"{"pricing_model": "buy_once", "price_dollars": 9.0}"#,
339 )
340 .await;
341 assert_eq!(
342 resp.status, 200,
343 "set project pricing failed: {}",
344 resp.text
345 );
346
347 h.client.post_form("/logout", "").await;
348
349 let resp = h.client.get("/p/paywall-proj").await;
350 assert_eq!(resp.status, 200, "paywalled project page should render");
351 assert!(
352 resp.text.contains("2 items included"),
353 "paywall should show how much is behind it; body was:\n{}",
354 resp.text
355 );
356 // Still a paywall: the item titles stay gated.
357 assert!(
358 !resp.text.contains("First Piece"),
359 "paywall must not list the items themselves"
360 );
361 }
362
363 // ── /pricing founder disclosure ──
364 //
365 // The page is the fee calculator, not a price list. Since `1e35bc8a` it is a
366 // described screen and the tier radio sends the tier's name rather than its
367 // price, so what has to be true is that the *price the page quotes* and the
368 // price the recompute computes on are both the founder one while the offer is
369 // live. A visitor who can buy at half price must not be shown their outcome at
370 // a price they will not pay.
371
372 /// With the window shut the page is what it was before any of this existed:
373 /// list prices, no banner, no mode question. No dead control.
374 #[tokio::test]
375 async fn pricing_page_without_founder_window_shows_list_prices_only() {
376 let mut h = TestHarness::new().await;
377
378 let resp = h.client.get("/pricing").await;
379 assert_eq!(resp.status, 200);
380 assert!(
381 !resp.text.contains("Founder pricing open"),
382 "founder banner rendered with the window shut"
383 );
384 assert!(
385 !resp.text.contains(r#"name="price_mode""#),
386 "price mode asked with the window shut, which is a dead control"
387 );
388
389 let prices = makenotwork::tier_prices::TierPrices::global();
390 assert!(
391 resp.text.contains(&format!("${}/mo.", prices.basic_std)),
392 "the Basic option should quote the list price"
393 );
394 }
395
396 /// With the window open the offer is disclosed and the calculator opens on the
397 /// founder price.
398 #[tokio::test]
399 async fn pricing_page_with_founder_window_computes_on_founder_prices() {
400 let mut h = TestHarness::with_founder_window_open().await;
401
402 let resp = h.client.get("/pricing").await;
403 assert_eq!(resp.status, 200);
404 assert!(
405 resp.text.contains("Founder pricing open"),
406 "the offer is live and undisclosed on the page that prices it"
407 );
408 assert!(
409 resp.text.contains(r#"name="price_mode""#),
410 "no way to see the post-window number"
411 );
412
413 let prices = makenotwork::tier_prices::TierPrices::global();
414 assert_ne!(
415 prices.basic_founder, prices.basic_std,
416 "fixture makes this test vacuous if the two prices are equal"
417 );
418 assert!(
419 resp.text
420 .contains(&format!("${}/mo.", prices.basic_founder)),
421 "the Basic option should open on the founder price"
422 );
423 }
424
425 /// The mode is a dial the server computes on, not a swap the browser performs.
426 ///
427 /// This is what deleted `<mnw-price-mode>`: the element held both prices in the
428 /// DOM and rewrote the radio's value, so the browser knew a price the server
429 /// also knew. Asking the recompute at each mode has to give two answers, or the
430 /// question is decoration.
431 #[tokio::test]
432 async fn the_price_mode_changes_what_the_recompute_computes_on() {
433 let mut h = TestHarness::with_founder_window_open().await;
434
435 // Through htmx, because a bare GET of a fragment endpoint is a navigation
436 // and `fragment_redirect` bounces it to the page. See `crate::fragment_redirect`.
437 let founder = h
438 .client
439 .htmx_get("/pricing/compare?tier=basic&price_mode=founder")
440 .await;
441 let list = h
442 .client
443 .htmx_get("/pricing/compare?tier=basic&price_mode=list")
444 .await;
445
446 assert_eq!(founder.status, 200);
447 assert_eq!(list.status, 200);
448 assert_ne!(
449 founder.text, list.text,
450 "both modes computed the same take-home, so the mode is not reaching \
451 the calculator"
452 );
453 }
454
455 /// A calculator URL written before the tier radio sent a name still lands on
456 /// the scenario it named.
457 #[tokio::test]
458 async fn a_shared_link_carrying_a_tier_price_still_computes_on_it() {
459 let mut h = TestHarness::new().await;
460
461 let resp = h
462 .client
463 .htmx_get("/pricing/compare?tier=16&sales=100")
464 .await;
465 assert_eq!(resp.status, 200);
466 assert!(!resp.text.is_empty(), "the recompute answered nothing");
467 }
468
469 // ── Fan+ benefit claims ──
470
471 /// The landing card and /fan-plus must name the same benefits, and only ones
472 /// the code grants.
473 ///
474 /// Two pages stating a benefit set by hand drift, and a claim survives that the
475 /// credit issuance path here and the perk checks in multithreaded do not grant.
476 /// This pins them together rather than trusting the next edit to touch both.
477 #[tokio::test]
478 async fn fan_plus_benefits_match_across_both_pages() {
479 let mut h = TestHarness::new().await;
480
481 let landing = h.client.get("/").await;
482 let fan_plus = h.client.get("/fan-plus").await;
483 assert_eq!(landing.status, 200);
484 assert_eq!(fan_plus.status, 200);
485
486 // Each entitlement, with the code that grants it:
487 // $5 monthly credit -> db::promo_codes::issue_fan_plus_credit_code
488 // + badge -> mt thread.html, gated on author_is_fan_plus
489 // Forum signatures -> mt account.rs, gated on UserPerks::effective_plus
490 // Image embeds -> mt reject_embeds_for_free_user
491 for claim in [
492 "$5 monthly credit",
493 "+ badge",
494 "Forum signatures",
495 "Image embeds",
496 ] {
497 assert!(
498 landing.text.contains(claim),
499 "landing page dropped the {claim} benefit"
500 );
501 assert!(
502 fan_plus.text.contains(claim),
503 "/fan-plus dropped the {claim} benefit"
504 );
505 }
506
507 // Retired claims, none of which named a real entitlement.
508 for gone in ["Platform polls", "Dev community access", "Early access"] {
509 assert!(
510 !landing.text.contains(gone),
511 "landing page still claims {gone}"
512 );
513 assert!(
514 !fan_plus.text.contains(gone),
515 "/fan-plus still claims {gone}"
516 );
517 }
518 }
519
520 /// A method mismatch on a path that exists renders the branded error page.
521 ///
522 /// GET /logout is the case that showed up in the wild: axum answered with a
523 /// bodiless 405, and the browser replaced it with its own network-error screen.
524 /// The route itself stays POST-plus-CSRF, so the GET must not log anyone out.
525 #[tokio::test]
526 async fn method_mismatch_renders_error_page() {
527 let mut h = TestHarness::new().await;
528
529 let user_id = h
530 .signup("methodmix", "methodmix@example.com", "password123")
531 .await;
532 assert!(!user_id.is_nil());
533
534 let resp = h.client.get("/logout").await;
535 assert_eq!(resp.status, 405, "GET /logout should be 405");
536 assert!(
537 resp.text.contains("error-page"),
538 "405 should render the error template, got: {}",
539 resp.text
540 );
541 assert!(
542 resp.text.contains("405"),
543 "error page should name the status, got: {}",
544 resp.text
545 );
546
547 // Still logged in: the 405 must not have performed the logout.
548 let resp = h.client.get("/dashboard").await;
549 assert_eq!(resp.status, 200, "GET /logout must not end the session");
550
551 // The same holds for any other method mismatch.
552 let resp = h.client.post_form("/login", "").await;
553 assert_ne!(resp.status, 405, "/login accepts POST");
554 // `/creators` is a route axum still owns, so this is what axum's own method
555 // fallback renders and is what this test is about.
556 let resp = h.client.delete("/creators").await;
557 assert_eq!(resp.status, 405, "DELETE /creators should be 405");
558 assert!(
559 resp.text.contains("error-page"),
560 "405 should render the error template, got: {}",
561 resp.text
562 );
563
564 // And `/pricing` again, which stood here until `1e35bc8a` made it a
565 // described screen and the description layer answered 404: the path existed
566 // and only the verb did not, which is what quasicoherent `370c0849` split
567 // apart. It answers 405 with `Allow` since quasi 0.80.
568 //
569 // No error template here, and that is the difference worth keeping in one
570 // test rather than two: the described screen refuses inside quasi and never
571 // reaches axum's fallback, so what comes back is the description layer's
572 // own notice. Both are 405; only one is this server's page.
573 let resp = h.client.delete("/pricing").await;
574 assert_eq!(resp.status, 405, "DELETE /pricing should be 405");
575 assert_eq!(
576 resp.headers.get("allow").and_then(|v| v.to_str().ok()),
577 Some("GET"),
578 "and it names the verb the address does take"
579 );
580 }
581
582 /// A 401 page offers the actions that resolve it, not only "Go Home".
583 ///
584 /// Anonymous GET /feed was a dead end: the one thing that fixes a 401 is
585 /// logging in, and the error page linked nowhere near it.
586 #[tokio::test]
587 async fn unauthorized_page_offers_login_and_signup() {
588 let mut h = TestHarness::new().await;
589
590 let resp = h.client.get("/feed").await;
591 assert_eq!(resp.status, 401, "anonymous /feed should be 401");
592 assert!(
593 resp.text.contains("href=\"/login\""),
594 "401 page should link to login, got: {}",
595 resp.text
596 );
597 assert!(
598 resp.text.contains("href=\"/join\""),
599 "401 page should link to signup, got: {}",
600 resp.text
601 );
602 assert!(
603 resp.text.contains("Go Home"),
604 "401 page should keep Go Home, got: {}",
605 resp.text
606 );
607
608 // The auth actions are gated on the status: a 404 keeps the single action.
609 let resp = h.client.get("/definitely-not-a-page").await;
610 assert_eq!(resp.status, 404);
611 assert!(
612 !resp.text.contains("Sign up"),
613 "404 page should not offer the auth actions, got: {}",
614 resp.text
615 );
616 }
617
618 /// The Askama Forums and Communities tabs fail soft on a transport failure.
619 ///
620 /// A timeout is a `send()` error, so a handler mapping `send()` errors to a 500
621 /// turns the five-second ceiling that exists to bound a slow Multithreaded into
622 /// the thing that breaks the tab. An unreachable address takes the same
623 /// branch.
624 #[tokio::test]
625 async fn forum_tabs_fail_soft_when_multithreaded_is_unreachable() {
626 let mut h = crate::harness::TestHarness::build(crate::harness::BuildOptions {
627 // Nothing listens here, so the request fails at the transport.
628 mt_base_url: Some("http://127.0.0.1:9".to_owned()),
629 ..Default::default()
630 })
631 .await;
632 h.signup("mtdown", "mtdown@example.com", "password123")
633 .await;
634
635 let resp = h.client.htmx_get("/dashboard/tabs/forums").await;
636 assert_eq!(
637 resp.status, 200,
638 "forums tab should render the empty state, got: {}",
639 resp.text
640 );
641
642 let resp = h.client.htmx_get("/library/tabs/communities").await;
643 assert_eq!(
644 resp.status, 200,
645 "communities tab should render the empty state, got: {}",
646 resp.text
647 );
648 }
649