Skip to main content

max / makenotwork

22.6 KB · 691 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. Before 2026-08-05 the paywall was built with a
297 /// hardcoded count of 0 and said nothing at all.
298 #[tokio::test]
299 async fn paywalled_project_page_shows_its_item_count() {
300 let mut h = TestHarness::new().await;
301
302 let user_id = h
303 .signup("paywalltest", "paywalltest@example.com", "password123")
304 .await;
305 h.grant_creator(user_id).await;
306 h.client.post_form("/logout", "").await;
307 h.login("paywalltest", "password123").await;
308
309 let resp = h
310 .client
311 .post_form("/api/projects", "slug=paywall-proj&title=Paywall+Project")
312 .await;
313 let project: Value = resp.json();
314 let project_id = project["id"].as_str().unwrap();
315
316 h.client
317 .put_json(
318 &format!("/api/projects/{project_id}"),
319 r#"{"is_public": true}"#,
320 )
321 .await;
322
323 for title in ["First+Piece", "Second+Piece"] {
324 let resp = h
325 .client
326 .post_form(
327 &format!("/api/projects/{project_id}/items"),
328 &format!("title={title}&item_type=text&is_public=true&price_cents=0"),
329 )
330 .await;
331 assert_eq!(resp.status, 200, "create item failed: {}", resp.text);
332 }
333
334 // Price the project itself, which is what puts the page behind the paywall.
335 let resp = h
336 .client
337 .put_json(
338 &format!("/api/projects/{project_id}"),
339 r#"{"pricing_model": "buy_once", "price_dollars": 9.0}"#,
340 )
341 .await;
342 assert_eq!(
343 resp.status, 200,
344 "set project pricing failed: {}",
345 resp.text
346 );
347
348 h.client.post_form("/logout", "").await;
349
350 let resp = h.client.get("/p/paywall-proj").await;
351 assert_eq!(resp.status, 200, "paywalled project page should render");
352 assert!(
353 resp.text.contains("2 items included"),
354 "paywall should show how much is behind it; body was:\n{}",
355 resp.text
356 );
357 // Still a paywall: the item titles stay gated.
358 assert!(
359 !resp.text.contains("First Piece"),
360 "paywall must not list the items themselves"
361 );
362 }
363
364 // ── /pricing founder disclosure ──
365 //
366 // The page is the fee calculator, not a price list: the tier radio's `value` is
367 // what hx-include sends, so it is the price the server computes the visitor's
368 // outcome on. While founder pricing is on offer that value has to be the
369 // founder price, or a visitor who can buy at half price is shown their outcome
370 // at 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 toggle. 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 toggle rendered with the window shut"
387 );
388
389 let prices = makenotwork::tier_prices::TierPrices::global();
390 assert!(
391 resp.text
392 .contains(&format!(r#"value="{}""#, prices.basic_std)),
393 "the Basic radio should carry the list price"
394 );
395 }
396
397 /// With the window open the offer is disclosed and the calculator opens on the
398 /// founder price, with both prices in the DOM so the toggle is a local swap.
399 #[tokio::test]
400 async fn pricing_page_with_founder_window_computes_on_founder_prices() {
401 let mut h = TestHarness::with_founder_window_open().await;
402
403 let resp = h.client.get("/pricing").await;
404 assert_eq!(resp.status, 200);
405 assert!(
406 resp.text.contains("Founder pricing open"),
407 "the offer is live and undisclosed on the page that prices it"
408 );
409 assert!(
410 resp.text.contains(r#"name="price-mode""#),
411 "no way to see the post-window number"
412 );
413
414 let prices = makenotwork::tier_prices::TierPrices::global();
415 assert_ne!(
416 prices.basic_founder, prices.basic_std,
417 "fixture makes this test vacuous if the two prices are equal"
418 );
419 assert!(
420 resp.text
421 .contains(&format!(r#"value="{}""#, prices.basic_founder)),
422 "the Basic radio should open on the founder price"
423 );
424 // Both prices present, so <mnw-price-mode> can flip without a round trip.
425 assert!(
426 resp.text
427 .contains(&format!(r#"data-price-std="{}""#, prices.basic_std)),
428 "list price missing from the DOM, the toggle would have to fetch it"
429 );
430 }
431
432 // ── Fan+ benefit claims ──
433
434 /// The landing card and /fan-plus must name the same benefits, and only ones
435 /// the code grants.
436 ///
437 /// They had drifted: the landing page claimed "Early access" on testnot.work
438 /// and both claimed "Platform polls" and "Dev community access". Audited
439 /// 2026-08-05 against the credit issuance path here and the perk checks in
440 /// multithreaded, none of those three was a thing Fan+ unlocks. Two pages
441 /// stating a benefit set by hand is how that happened, so this pins them
442 /// together rather than trusting the next edit to touch both.
443 #[tokio::test]
444 async fn fan_plus_benefits_match_across_both_pages() {
445 let mut h = TestHarness::new().await;
446
447 let landing = h.client.get("/").await;
448 let fan_plus = h.client.get("/fan-plus").await;
449 assert_eq!(landing.status, 200);
450 assert_eq!(fan_plus.status, 200);
451
452 // Each entitlement, with the code that grants it:
453 // $5 monthly credit -> db::promo_codes::issue_fan_plus_credit_code
454 // + badge -> mt thread.html, gated on author_is_fan_plus
455 // Forum signatures -> mt account.rs, gated on UserPerks::effective_plus
456 // Image embeds -> mt reject_embeds_for_free_user
457 for claim in [
458 "$5 monthly credit",
459 "+ badge",
460 "Forum signatures",
461 "Image embeds",
462 ] {
463 assert!(
464 landing.text.contains(claim),
465 "landing page dropped the {claim} benefit"
466 );
467 assert!(
468 fan_plus.text.contains(claim),
469 "/fan-plus dropped the {claim} benefit"
470 );
471 }
472
473 // Retired claims, none of which named a real entitlement.
474 for gone in ["Platform polls", "Dev community access", "Early access"] {
475 assert!(
476 !landing.text.contains(gone),
477 "landing page still claims {gone}"
478 );
479 assert!(
480 !fan_plus.text.contains(gone),
481 "/fan-plus still claims {gone}"
482 );
483 }
484 }
485
486 // ── Landing notify-me capture ──
487
488 /// The form works with JS off.
489 ///
490 /// It had no action and no method, so a browser without JS submitted a GET to
491 /// `/` and the address was dropped silently. This is the only email capture on
492 /// the landing page, so a silent loss is a lost signup nobody can count.
493 #[tokio::test]
494 async fn notify_form_without_js_stores_the_address() {
495 let mut h = TestHarness::new().await;
496 h.client.fetch_csrf_token().await;
497
498 let resp = h
499 .client
500 .post_form("/notify", "email=nojs@example.com")
501 .await;
502 assert_eq!(
503 resp.status, 303,
504 "expected a redirect back to the landing page"
505 );
506 let location = resp
507 .headers
508 .get("location")
509 .and_then(|v| v.to_str().ok())
510 .unwrap_or_default();
511 assert!(
512 location.starts_with("/?notify=ok"),
513 "redirected to {location}"
514 );
515
516 let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1")
517 .bind("nojs@example.com")
518 .fetch_one(&h.db)
519 .await
520 .expect("count signups");
521 assert_eq!(stored, 1, "the address never reached storage");
522 }
523
524 /// A rejected address comes back as a sentence on the page, not a 422. This is
525 /// the last thing on the landing page and an error code is a worse outcome
526 /// than being told the address looked wrong.
527 #[tokio::test]
528 async fn notify_form_rejects_a_bad_address_without_erroring() {
529 let mut h = TestHarness::new().await;
530 h.client.fetch_csrf_token().await;
531
532 let resp = h.client.post_form("/notify", "email=not-an-address").await;
533 assert_eq!(resp.status, 303);
534 assert!(
535 resp.headers
536 .get("location")
537 .and_then(|v| v.to_str().ok())
538 .unwrap_or_default()
539 .starts_with("/?notify=invalid")
540 );
541
542 let landing = h.client.get("/?notify=invalid").await;
543 assert!(
544 landing.text.contains("That address didn't look right."),
545 "the landing page said nothing about the rejection"
546 );
547 }
548
549 /// The success message renders for a no-JS visitor coming back off the
550 /// redirect, and not on a plain page load.
551 #[tokio::test]
552 async fn notify_status_renders_only_when_the_redirect_says_so() {
553 let mut h = TestHarness::new().await;
554
555 let plain = h.client.get("/").await;
556 assert!(
557 !plain.text.contains("You're on the list."),
558 "status shown on a plain load"
559 );
560
561 let after = h.client.get("/?notify=ok").await;
562 assert!(after.text.contains("You're on the list."));
563 }
564
565 // ── Landing signup unsubscribe ──
566 //
567 // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and
568 // count, and no way out. Withdrawal has to be as easy as consent, and consent
569 // is one form field.
570
571 fn signup_unsub_url(email: &str) -> String {
572 makenotwork::email::generate_signup_unsubscribe_url(
573 "",
574 email,
575 "test-signing-secret-for-integration-tests",
576 )
577 }
578
579 /// The signed link unsubscribes, and the address stops being mailable.
580 #[tokio::test]
581 async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() {
582 let mut h = TestHarness::new().await;
583 h.client.fetch_csrf_token().await;
584 h.client
585 .post_form("/notify", "email=leaving@example.com")
586 .await;
587
588 let before: i64 = sqlx::query_scalar(
589 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
590 )
591 .bind("leaving@example.com")
592 .fetch_one(&h.db)
593 .await
594 .expect("count");
595 assert_eq!(before, 1, "signup did not land");
596
597 let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await;
598 assert_eq!(resp.status, 200);
599
600 let after: i64 = sqlx::query_scalar(
601 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
602 )
603 .bind("leaving@example.com")
604 .fetch_one(&h.db)
605 .await
606 .expect("count");
607 assert_eq!(after, 0, "still mailable after unsubscribing");
608
609 // Marked, not deleted: a deleted address is one the next import re-adds.
610 let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1")
611 .bind("leaving@example.com")
612 .fetch_one(&h.db)
613 .await
614 .expect("count");
615 assert_eq!(retained, 1, "the opt-out record was thrown away");
616 }
617
618 /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step,
619 /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo.
620 /// Retries must not fail, so the second POST answers the same as the first.
621 #[tokio::test]
622 async fn signup_unsubscribe_one_click_post_is_idempotent() {
623 let mut h = TestHarness::new().await;
624 h.client.fetch_csrf_token().await;
625 h.client
626 .post_form("/notify", "email=oneclick@example.com")
627 .await;
628
629 let url = signup_unsub_url("oneclick@example.com");
630 let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
631 assert_eq!(first.status, 200);
632 let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
633 assert_eq!(second.status, 200, "a retried one-click POST must not fail");
634 }
635
636 /// A tampered signature does nothing. The address is signed over, so a token
637 /// for one address cannot unsubscribe another.
638 #[tokio::test]
639 async fn signup_unsubscribe_rejects_a_forged_link() {
640 let mut h = TestHarness::new().await;
641 h.client.fetch_csrf_token().await;
642 h.client
643 .post_form("/notify", "email=victim@example.com")
644 .await;
645
646 // Take a valid token for one address and point it at another.
647 let forged = signup_unsub_url("attacker@example.com")
648 .replace("attacker%40example.com", "victim%40example.com");
649 let resp = h.client.get(&forged).await;
650 assert!(
651 resp.text.contains("Invalid Link"),
652 "a forged link was accepted"
653 );
654
655 let still_mailable: i64 = sqlx::query_scalar(
656 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
657 )
658 .bind("victim@example.com")
659 .fetch_one(&h.db)
660 .await
661 .expect("count");
662 assert_eq!(still_mailable, 1, "a forged link unsubscribed someone");
663 }
664
665 /// Signing up again after unsubscribing re-subscribes. The form is the only
666 /// interface, so refusing would leave someone unable to opt back in.
667 #[tokio::test]
668 async fn signing_up_again_after_unsubscribing_restores_the_subscription() {
669 let mut h = TestHarness::new().await;
670 h.client.fetch_csrf_token().await;
671 h.client
672 .post_form("/notify", "email=returning@example.com")
673 .await;
674 h.client
675 .get(&signup_unsub_url("returning@example.com"))
676 .await;
677
678 h.client
679 .post_form("/notify", "email=returning@example.com")
680 .await;
681
682 let mailable: i64 = sqlx::query_scalar(
683 "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL",
684 )
685 .bind("returning@example.com")
686 .fetch_one(&h.db)
687 .await
688 .expect("count");
689 assert_eq!(mailable, 1, "could not opt back in");
690 }
691