Skip to main content

max / makenotwork

34.8 KB · 1141 lines History Blame Raw
1 //! Integration tests for project and item creation wizards.
2
3 use crate::harness::TestHarness;
4
5 #[tokio::test]
6 async fn join_wizard_htmx_validation_preserves_input() {
7 // UX-S1 (Run #23): a failed HTMX account-step submit must re-render the step
8 // with the typed username/email intact and the bad field flagged, not wipe
9 // the whole form with a bare error line.
10 let mut h = TestHarness::new().await;
11
12 // Seed a taken username so the second signup's username step fails validation.
13 h.signup("taken_name", "first@test.com", "password123")
14 .await;
15 h.client.post_form("/logout", "").await;
16 h.client.fetch_csrf_token().await;
17
18 let body = "username=taken_name&email=kept@example.com&password=password123";
19 let resp = h.client.htmx_post_form("/join/step/account", body).await;
20
21 assert!(
22 resp.status.is_success(),
23 "expected a 200 re-render, got {}: {}",
24 resp.status,
25 resp.text
26 );
27 // The re-rendered step keeps the typed values and is the account form, not a
28 // bare error fragment.
29 assert!(
30 resp.text.contains("already taken"),
31 "shows the validation error: {}",
32 resp.text
33 );
34 assert!(
35 resp.text.contains("value=\"taken_name\""),
36 "preserves the typed username: {}",
37 resp.text
38 );
39 assert!(
40 resp.text.contains("value=\"kept@example.com\""),
41 "preserves the typed email: {}",
42 resp.text
43 );
44 assert!(
45 resp.text.contains("aria-invalid=\"true\""),
46 "flags the offending field: {}",
47 resp.text
48 );
49 assert!(
50 resp.text.contains("name=\"password\""),
51 "the full form is re-rendered, not just an error line"
52 );
53 }
54
55 // Project Wizard
56
57 #[tokio::test]
58 async fn project_wizard_full_flow() {
59 let mut h = TestHarness::new().await;
60 let _user_id = h.create_creator("pwiz").await;
61
62 // Load wizard page
63 let resp = h.client.get("/dashboard/new-project").await;
64 assert_eq!(resp.status, 200);
65 assert!(resp.text.contains("Basics"), "Should show step 1");
66
67 // Step 1: Basics, creates project
68 let resp = h
69 .client
70 .post_form(
71 "/dashboard/new-project/step/basics",
72 "title=Wizard+Test&slug=wizard-test&project_type=blog&description=A+test+project",
73 )
74 .await;
75 assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text);
76 assert!(resp.text.contains("Appearance"), "Should advance to step 2");
77
78 // Verify project created in DB
79 let exists: bool =
80 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM projects WHERE slug = 'wizard-test')")
81 .fetch_one(&h.db)
82 .await
83 .unwrap();
84 assert!(exists, "Project should exist");
85
86 // Step 2: Appearance, skip (no cover image)
87 let resp = h
88 .client
89 .post_form("/dashboard/new-project/wizard-test/step/appearance", "")
90 .await;
91 assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text);
92 assert!(
93 resp.text.contains("Monetization"),
94 "Should advance to step 3"
95 );
96
97 // Step 3: Monetization, skip (no tiers). `pricing_model` is required by
98 // `save_monetization` (launch-eve hardening: closed silent fallback to
99 // Free); the wizard select defaults to "free" in the rendered form, so
100 // mirror that here.
101 let resp = h
102 .client
103 .post_form(
104 "/dashboard/new-project/wizard-test/step/monetization",
105 "pricing_model=free",
106 )
107 .await;
108 assert!(resp.status.is_success(), "Step 3 failed: {}", resp.text);
109
110 // Step 4: First content, skip
111 let resp = h
112 .client
113 .post_form("/dashboard/new-project/wizard-test/step/first-content", "")
114 .await;
115 assert!(resp.status.is_success(), "Step 4 failed: {}", resp.text);
116
117 // Step 5: Preview, publish
118 let resp = h
119 .client
120 .post_form(
121 "/dashboard/new-project/wizard-test/step/preview",
122 "action=publish",
123 )
124 .await;
125 // Preview step returns HX-Redirect
126 assert!(
127 resp.headers
128 .get("hx-redirect")
129 .is_some_and(|v| v.to_str().unwrap().contains("wizard-test")),
130 "Should redirect to project dashboard"
131 );
132
133 // Verify project is public (publish action confirms is_public = true)
134 let is_public: bool =
135 sqlx::query_scalar("SELECT is_public FROM projects WHERE slug = 'wizard-test'")
136 .fetch_one(&h.db)
137 .await
138 .unwrap();
139 assert!(is_public, "Project should be published");
140 }
141
142 /// Regression (ultra-fuzz Run 11 UX F1): the $10k price cap must hold on the
143 /// creation-wizard monetization step, not just the JSON API. A buy-once price
144 /// above the cap is rejected, and no over-cap price is persisted.
145 #[tokio::test]
146 async fn project_wizard_buy_once_price_cap_enforced() {
147 let mut h = TestHarness::new().await;
148 let _user_id = h.create_creator("capwiz").await;
149
150 h.client
151 .post_form(
152 "/dashboard/new-project/step/basics",
153 "title=Cap+Test&slug=cap-test&project_type=blog&description=A+test",
154 )
155 .await;
156 h.client
157 .post_form("/dashboard/new-project/cap-test/step/appearance", "")
158 .await;
159
160 // $20,000, well over the $10,000 cap.
161 let resp = h
162 .client
163 .post_form(
164 "/dashboard/new-project/cap-test/step/monetization",
165 "pricing_model=buy_once&price_dollars=20000",
166 )
167 .await;
168 assert!(
169 !resp.status.is_success()
170 || resp.text.to_lowercase().contains("10,000")
171 || resp.text.to_lowercase().contains("exceed"),
172 "over-cap wizard price should be rejected: {} {}",
173 resp.status,
174 resp.text
175 );
176
177 // Nothing over the cap was persisted.
178 let price: Option<i32> =
179 sqlx::query_scalar("SELECT price_cents FROM projects WHERE slug = 'cap-test'")
180 .fetch_one(&h.db)
181 .await
182 .unwrap();
183 assert!(
184 price.unwrap_or(0) <= 1_000_000,
185 "persisted price_cents {price:?} must not exceed the $10k cap"
186 );
187 }
188
189 #[tokio::test]
190 async fn project_wizard_save_as_draft() {
191 let mut h = TestHarness::new().await;
192 let _user_id = h.create_creator("pdraft").await;
193
194 // Step 1: Create project
195 h.client
196 .post_form(
197 "/dashboard/new-project/step/basics",
198 "title=Draft+Project&slug=draft-proj&project_type=music",
199 )
200 .await;
201
202 // Skip through steps 2-4
203 h.client
204 .post_form("/dashboard/new-project/draft-proj/step/appearance", "")
205 .await;
206 h.client
207 .post_form("/dashboard/new-project/draft-proj/step/monetization", "")
208 .await;
209 h.client
210 .post_form("/dashboard/new-project/draft-proj/step/first-content", "")
211 .await;
212
213 // Step 5: Save as draft (redirects to project dashboard without changing state)
214 let resp = h
215 .client
216 .post_form(
217 "/dashboard/new-project/draft-proj/step/preview",
218 "action=draft",
219 )
220 .await;
221 assert!(
222 resp.headers
223 .get("hx-redirect")
224 .is_some_and(|v| v.to_str().unwrap().contains("draft-proj")),
225 "Should redirect to project dashboard"
226 );
227
228 // Verify project exists
229 let exists: bool =
230 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM projects WHERE slug = 'draft-proj')")
231 .fetch_one(&h.db)
232 .await
233 .unwrap();
234 assert!(exists, "Draft project should exist");
235 }
236
237 // Item Wizard
238
239 #[tokio::test]
240 async fn item_wizard_full_flow() {
241 let mut h = TestHarness::new().await;
242 let _user_id = h.create_creator("iwiz").await;
243
244 // Create a project first via API
245 let resp = h
246 .client
247 .post_form("/api/projects", "slug=iwiz-proj&title=Item+Wizard+Test")
248 .await;
249 assert!(resp.status.is_success());
250
251 // Load item wizard page
252 let resp = h.client.get("/dashboard/project/iwiz-proj/new-item").await;
253 assert_eq!(resp.status, 200);
254 assert!(resp.text.contains("Type"), "Should show step 1 (type)");
255
256 // Step 1: Type, creates item
257 let resp = h
258 .client
259 .post_form(
260 "/dashboard/project/iwiz-proj/new-item/step/type",
261 "item_type=text",
262 )
263 .await;
264 assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text);
265 assert!(
266 resp.text.contains("Basics"),
267 "Should advance to step 2 (basics)"
268 );
269
270 // Extract item ID from DB
271 let item_id: String = sqlx::query_scalar(
272 "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'iwiz-proj' ORDER BY i.created_at DESC LIMIT 1",
273 )
274 .fetch_one(&h.db)
275 .await
276 .unwrap();
277
278 // Step 2: Basics
279 let resp = h
280 .client
281 .post_form(
282 &format!("/dashboard/project/iwiz-proj/new-item/{item_id}/step/basics"),
283 "title=My+First+Article&description=A+great+article",
284 )
285 .await;
286 assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text);
287 assert!(
288 resp.text.contains("Content"),
289 "Should advance to step 3 (content)"
290 );
291
292 // Step 3: Content (text body)
293 let resp = h
294 .client
295 .post_form(
296 &format!("/dashboard/project/iwiz-proj/new-item/{item_id}/step/content"),
297 "body=Hello+world",
298 )
299 .await;
300 assert!(resp.status.is_success(), "Step 3 failed: {}", resp.text);
301 assert!(
302 resp.text.contains("Pricing"),
303 "Should advance to step 4 (pricing)"
304 );
305
306 // Step 4: Pricing (free)
307 let resp = h
308 .client
309 .post_form(
310 &format!("/dashboard/project/iwiz-proj/new-item/{item_id}/step/pricing"),
311 "pricing_model=free",
312 )
313 .await;
314 assert!(resp.status.is_success(), "Step 4 failed: {}", resp.text);
315
316 // Step 5: Preview, publish
317 let resp = h
318 .client
319 .post_form(
320 &format!("/dashboard/project/iwiz-proj/new-item/{item_id}/step/preview"),
321 "action=publish",
322 )
323 .await;
324 assert!(
325 resp.headers.get("hx-redirect").is_some(),
326 "Should redirect after publish"
327 );
328
329 // Verify item is published
330 let is_public: bool = sqlx::query_scalar(&format!(
331 "SELECT is_public FROM items WHERE id = '{item_id}'"
332 ))
333 .fetch_one(&h.db)
334 .await
335 .unwrap();
336 assert!(is_public, "Item should be published");
337 }
338
339 #[tokio::test]
340 async fn item_wizard_pricing_models() {
341 let mut h = TestHarness::new().await;
342 let _user_id = h.create_creator("ipricing").await;
343
344 let resp = h
345 .client
346 .post_form("/api/projects", "slug=ipricing-proj&title=Pricing+Test")
347 .await;
348 assert!(resp.status.is_success());
349
350 // Create item via wizard step 1
351 h.client
352 .post_form(
353 "/dashboard/project/ipricing-proj/new-item/step/type",
354 "item_type=digital",
355 )
356 .await;
357
358 let item_id: String = sqlx::query_scalar(
359 "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'ipricing-proj' ORDER BY i.created_at DESC LIMIT 1",
360 )
361 .fetch_one(&h.db)
362 .await
363 .unwrap();
364
365 // Test fixed pricing
366 let resp = h
367 .client
368 .post_form(
369 &format!("/dashboard/project/ipricing-proj/new-item/{item_id}/step/pricing"),
370 "pricing_model=fixed&price=9.99",
371 )
372 .await;
373 assert!(resp.status.is_success(), "Fixed pricing failed");
374
375 let price: i32 = sqlx::query_scalar(&format!(
376 "SELECT price_cents FROM items WHERE id = '{item_id}'"
377 ))
378 .fetch_one(&h.db)
379 .await
380 .unwrap();
381 assert_eq!(price, 999, "Price should be 999 cents");
382
383 // Navigate back to pricing and test PWYW
384 let resp = h
385 .client
386 .post_form(
387 &format!("/dashboard/project/ipricing-proj/new-item/{item_id}/step/pricing"),
388 "pricing_model=pwyw&suggested_price=5.00&min_price=1.00",
389 )
390 .await;
391 assert!(resp.status.is_success(), "PWYW pricing failed");
392
393 let (pwyw_enabled, price_cents, min_cents): (bool, i32, Option<i32>) =
394 sqlx::query_as(&format!(
395 "SELECT pwyw_enabled, price_cents, pwyw_min_cents FROM items WHERE id = '{item_id}'"
396 ))
397 .fetch_one(&h.db)
398 .await
399 .unwrap();
400 assert!(pwyw_enabled, "PWYW should be enabled");
401 assert_eq!(price_cents, 500, "Suggested price should be 500 cents");
402 assert_eq!(min_cents, Some(100), "Min price should be 100 cents");
403
404 // Test free pricing
405 let resp = h
406 .client
407 .post_form(
408 &format!("/dashboard/project/ipricing-proj/new-item/{item_id}/step/pricing"),
409 "pricing_model=free",
410 )
411 .await;
412 assert!(resp.status.is_success(), "Free pricing failed");
413
414 let (price, pwyw): (i32, bool) = sqlx::query_as(&format!(
415 "SELECT price_cents, pwyw_enabled FROM items WHERE id = '{item_id}'"
416 ))
417 .fetch_one(&h.db)
418 .await
419 .unwrap();
420 assert_eq!(price, 0, "Price should be 0 for free");
421 assert!(!pwyw, "PWYW should be disabled");
422 }
423
424 #[tokio::test]
425 async fn item_wizard_schedule_publish() {
426 let mut h = TestHarness::new().await;
427 let _user_id = h.create_creator("isched").await;
428
429 let resp = h
430 .client
431 .post_form("/api/projects", "slug=isched-proj&title=Schedule+Test")
432 .await;
433 assert!(resp.status.is_success());
434
435 // Create item
436 h.client
437 .post_form(
438 "/dashboard/project/isched-proj/new-item/step/type",
439 "item_type=audio",
440 )
441 .await;
442
443 let item_id: String = sqlx::query_scalar(
444 "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'isched-proj' LIMIT 1",
445 )
446 .fetch_one(&h.db)
447 .await
448 .unwrap();
449
450 // Schedule publish via preview step
451 let resp = h
452 .client
453 .post_form(
454 &format!("/dashboard/project/isched-proj/new-item/{item_id}/step/preview"),
455 "action=schedule&publish_at=2030-01-15T10%3A00",
456 )
457 .await;
458 assert!(
459 resp.headers.get("hx-redirect").is_some(),
460 "Should redirect after schedule"
461 );
462
463 // Verify publish_at is set
464 let has_publish_at: bool = sqlx::query_scalar(&format!(
465 "SELECT publish_at IS NOT NULL FROM items WHERE id = '{item_id}'"
466 ))
467 .fetch_one(&h.db)
468 .await
469 .unwrap();
470 assert!(
471 has_publish_at,
472 "publish_at should be set for scheduled item"
473 );
474 }
475
476 // Auth & Access Control
477
478 #[tokio::test]
479 async fn wizard_auth_required() {
480 let mut h = TestHarness::new().await;
481
482 // Unauthenticated access should redirect to login
483 let resp = h.client.get("/dashboard/new-project").await;
484 assert!(
485 resp.status.is_redirection() || resp.status == 401 || resp.status == 403,
486 "Unauthenticated user should not access wizard, got {}",
487 resp.status
488 );
489
490 let resp = h.client.get("/dashboard/project/anything/new-item").await;
491 assert!(
492 resp.status.is_redirection() || resp.status == 401 || resp.status == 403,
493 "Unauthenticated user should not access item wizard, got {}",
494 resp.status
495 );
496 }
497
498 #[tokio::test]
499 async fn wizard_ownership_check() {
500 let mut h = TestHarness::new().await;
501
502 // Creator A creates a project
503 let _user_a = h.create_creator("wizown_a").await;
504 let resp = h
505 .client
506 .post_form("/api/projects", "slug=owned-proj&title=Owned")
507 .await;
508 assert!(resp.status.is_success());
509
510 // Create item via wizard
511 h.client
512 .post_form(
513 "/dashboard/project/owned-proj/new-item/step/type",
514 "item_type=text",
515 )
516 .await;
517 let item_id: String = sqlx::query_scalar(
518 "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'owned-proj' LIMIT 1",
519 )
520 .fetch_one(&h.db)
521 .await
522 .unwrap();
523
524 // Creator B logs in
525 h.client.post_form("/logout", "").await;
526 let _user_b = h.create_creator("wizown_b").await;
527
528 // Creator B should not access A's item wizard step
529 let resp = h
530 .client
531 .get(&format!(
532 "/dashboard/project/owned-proj/new-item/{item_id}/step/details"
533 ))
534 .await;
535 assert!(
536 resp.status == 404 || resp.status == 403,
537 "Non-owner should get 404 or 403, got {}",
538 resp.status
539 );
540
541 // Creator B should not access A's project wizard step
542 let resp = h
543 .client
544 .get("/dashboard/new-project/owned-proj/step/appearance")
545 .await;
546 assert!(
547 resp.status == 404 || resp.status == 403,
548 "Non-owner should not access other's project wizard, got {}",
549 resp.status
550 );
551 }
552
553 #[tokio::test]
554 async fn wizard_back_navigation() {
555 let mut h = TestHarness::new().await;
556 let _user_id = h.create_creator("wback").await;
557
558 // Create project via wizard
559 let resp = h
560 .client
561 .post_form(
562 "/dashboard/new-project/step/basics",
563 "title=Back+Nav&slug=back-nav&project_type=general",
564 )
565 .await;
566 assert!(resp.status.is_success());
567
568 // Go forward to monetization
569 h.client
570 .post_form("/dashboard/new-project/back-nav/step/appearance", "")
571 .await;
572
573 // Navigate back to basics step via GET
574 let resp = h
575 .client
576 .get("/dashboard/new-project/back-nav/step/basics")
577 .await;
578 assert!(
579 resp.status.is_success(),
580 "GET basics step failed: {} {}",
581 resp.status,
582 resp.text
583 );
584 assert!(
585 resp.text.contains("Back Nav"),
586 "Should show saved title when navigating back"
587 );
588
589 // Navigate back to appearance
590 let resp = h
591 .client
592 .get("/dashboard/new-project/back-nav/step/appearance")
593 .await;
594 assert!(resp.status.is_success());
595 }
596
597 // Wizard Features
598
599 #[tokio::test]
600 async fn item_wizard_license_keys() {
601 let mut h = TestHarness::new().await;
602 let _user_id = h.create_creator("ilickey").await;
603
604 let resp = h
605 .client
606 .post_form("/api/projects", "slug=lickey-proj&title=License+Test")
607 .await;
608 assert!(resp.status.is_success());
609
610 // Create plugin item (license keys relevant for this type)
611 h.client
612 .post_form(
613 "/dashboard/project/lickey-proj/new-item/step/type",
614 "item_type=plugin",
615 )
616 .await;
617
618 let item_id: String = sqlx::query_scalar(
619 "SELECT i.id::text FROM items i JOIN projects p ON i.project_id = p.id WHERE p.slug = 'lickey-proj' LIMIT 1",
620 )
621 .fetch_one(&h.db)
622 .await
623 .unwrap();
624
625 // Enable license keys via the API (no longer a wizard step)
626 let resp = h
627 .client
628 .put_form(
629 &format!("/api/items/{item_id}/license-settings"),
630 "enable_license_keys=on&default_max_activations=5",
631 )
632 .await;
633 assert!(
634 resp.status.is_success(),
635 "License settings update failed: {}",
636 resp.text
637 );
638
639 // Verify license settings saved
640 let (enabled, max_act): (bool, Option<i32>) = sqlx::query_as(&format!(
641 "SELECT enable_license_keys, default_max_activations FROM items WHERE id = '{item_id}'"
642 ))
643 .fetch_one(&h.db)
644 .await
645 .unwrap();
646 assert!(enabled, "License keys should be enabled");
647 assert_eq!(max_act, Some(5), "Max activations should be 5");
648 }
649
650 #[tokio::test]
651 async fn project_wizard_subscription_tiers() {
652 let mut h = TestHarness::new().await;
653 let _user_id = h.create_creator("ptiers").await;
654
655 // Create project via wizard
656 h.client
657 .post_form(
658 "/dashboard/new-project/step/basics",
659 "title=Tier+Test&slug=tier-test&project_type=podcast",
660 )
661 .await;
662
663 // Skip appearance
664 h.client
665 .post_form("/dashboard/new-project/tier-test/step/appearance", "")
666 .await;
667
668 // Create tiers in monetization step
669 let resp = h
670 .client
671 .post_form(
672 "/dashboard/new-project/tier-test/step/monetization",
673 "pricing_model=subscription&tier_name_0=Basic&tier_price_0=5.00&tier_desc_0=Basic+access&tier_name_1=Pro&tier_price_1=15.00&tier_desc_1=Full+access",
674 )
675 .await;
676 assert!(
677 resp.status.is_success(),
678 "Monetization step failed: {}",
679 resp.text
680 );
681
682 // Verify tiers created
683 let tier_count: i64 = sqlx::query_scalar(
684 "SELECT COUNT(*) FROM subscription_tiers t JOIN projects p ON t.project_id = p.id WHERE p.slug = 'tier-test'",
685 )
686 .fetch_one(&h.db)
687 .await
688 .unwrap();
689 assert_eq!(tier_count, 2, "Should have created 2 tiers");
690 }
691
692 // Join Wizard
693
694 #[tokio::test]
695 async fn join_wizard_full_flow() {
696 let mut h = TestHarness::new().await;
697 h.client.fetch_csrf_token().await;
698
699 // Load wizard page
700 let resp = h.client.get("/join").await;
701 assert_eq!(resp.status, 200);
702 assert!(resp.text.contains("Create account"), "Should show step 1");
703 assert!(resp.text.contains("wizard-steps"), "Should have step nav");
704
705 // Step 1: Account, creates user and logs in
706 let resp = h
707 .client
708 .post_form(
709 "/join/step/account",
710 "username=jwiz&email=jwiz@test.com&password=testpassword123",
711 )
712 .await;
713 assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text);
714 assert!(resp.text.contains("Profile"), "Should advance to step 2");
715
716 // Verify user created in DB
717 let _user_id: makenotwork::db::UserId =
718 sqlx::query_scalar("SELECT id FROM users WHERE username = 'jwiz'")
719 .fetch_one(&h.db)
720 .await
721 .unwrap();
722
723 // Step 2: Profile, update display_name and bio
724 let resp = h
725 .client
726 .post_form(
727 "/join/step/profile",
728 "display_name=Join+Wizard&bio=Testing+the+wizard",
729 )
730 .await;
731 assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text);
732 // Profile now goes directly to welcome
733 assert!(
734 resp.text.contains("Welcome"),
735 "Should advance to welcome step"
736 );
737
738 // Verify profile saved
739 let display_name: Option<String> =
740 sqlx::query_scalar("SELECT display_name FROM users WHERE username = 'jwiz'")
741 .fetch_one(&h.db)
742 .await
743 .unwrap();
744 assert_eq!(display_name.as_deref(), Some("Join Wizard"));
745
746 // Welcome page should have intent branching
747 assert!(
748 resp.text.contains("Browse and buy") || resp.text.contains("I want to sell"),
749 "Should show intent options"
750 );
751 }
752
753 #[tokio::test]
754 async fn join_wizard_skip_all_optional() {
755 let mut h = TestHarness::new().await;
756 h.client.fetch_csrf_token().await;
757
758 // Step 1: Create account
759 let resp = h
760 .client
761 .post_form(
762 "/join/step/account",
763 "username=jskip&email=jskip@test.com&password=testpassword123",
764 )
765 .await;
766 assert!(resp.status.is_success(), "Step 1 failed: {}", resp.text);
767
768 // Skip directly to complete
769 let resp = h.client.get("/join/step/complete").await;
770 assert!(resp.status.is_success());
771 assert!(resp.text.contains("Welcome"));
772
773 // Verify user exists, no profile update, no waitlist entry
774 let display_name: Option<String> =
775 sqlx::query_scalar("SELECT display_name FROM users WHERE username = 'jskip'")
776 .fetch_one(&h.db)
777 .await
778 .unwrap();
779 assert!(display_name.is_none(), "Display name should not be set");
780
781 let has_waitlist: bool = sqlx::query_scalar(
782 "SELECT EXISTS(SELECT 1 FROM creator_waitlist WHERE user_id = (SELECT id FROM users WHERE username = 'jskip'))",
783 )
784 .fetch_one(&h.db)
785 .await
786 .unwrap();
787 assert!(!has_waitlist, "No waitlist entry should exist");
788 }
789
790 #[tokio::test]
791 async fn join_wizard_with_invite_code() {
792 let mut h = TestHarness::new().await;
793
794 // Create a creator who can issue invites
795 let creator_id = h.create_creator("jinviter").await;
796
797 // Create an invite code directly in DB. Codes are stored hashed, so insert
798 // the hash of the raw code the wizard will submit.
799 sqlx::query("INSERT INTO invite_codes (creator_id, code) VALUES ($1, $2)")
800 .bind(creator_id)
801 .bind(makenotwork::crypto::invite_code_hash("TESTCODE"))
802 .execute(&h.db)
803 .await
804 .unwrap();
805
806 // Log out the creator
807 h.client.post_form("/logout", "").await;
808 h.client.fetch_csrf_token().await;
809
810 // Load join page with invite code
811 let resp = h.client.get("/join?invite=TESTCODE").await;
812 assert_eq!(resp.status, 200);
813 assert!(resp.text.contains("invited"), "Should show invite notice");
814
815 // Sign up with invite code
816 let resp = h
817 .client
818 .post_form(
819 "/join/step/account",
820 "username=jinvitee&email=jinvitee@test.com&password=testpassword123&invite_code=TESTCODE",
821 )
822 .await;
823 assert!(
824 resp.status.is_success(),
825 "Signup with invite failed: {}",
826 resp.text
827 );
828
829 // Verify invite redeemed (query by creator; the code column holds a hash now)
830 let redeemed: bool = sqlx::query_scalar(
831 "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE creator_id = $1",
832 )
833 .bind(creator_id)
834 .fetch_one(&h.db)
835 .await
836 .unwrap();
837 assert!(redeemed, "Invite should be redeemed");
838
839 // Verify waitlist entry created (invited type)
840 let has_waitlist: bool = sqlx::query_scalar(
841 "SELECT EXISTS(SELECT 1 FROM creator_waitlist WHERE user_id = (SELECT id FROM users WHERE username = 'jinvitee') AND selection_method = 'invited')",
842 )
843 .fetch_one(&h.db)
844 .await
845 .unwrap();
846 assert!(has_waitlist, "Invited waitlist entry should exist");
847 }
848
849 #[tokio::test]
850 async fn join_wizard_validation_errors() {
851 let mut h = TestHarness::new().await;
852 h.client.fetch_csrf_token().await;
853
854 // Short password (HTMX: returns error fragment with 200; non-HTMX: returns 422)
855 let resp = h
856 .client
857 .htmx_post_form(
858 "/join/step/account",
859 "username=jval&email=jval@test.com&password=short",
860 )
861 .await;
862 assert!(resp.status.is_success());
863 assert!(
864 resp.text.contains("8 characters"),
865 "Should show password error"
866 );
867
868 // Bad email
869 let resp = h
870 .client
871 .htmx_post_form(
872 "/join/step/account",
873 "username=jval&email=bademail&password=testpassword123",
874 )
875 .await;
876 assert!(resp.status.is_success());
877 assert!(resp.text.contains("valid email"), "Should show email error");
878
879 // Create a user, then try duplicate username
880 h.signup("jexisting", "jexisting@test.com", "testpassword123")
881 .await;
882 h.client.post_form("/logout", "").await;
883 h.client.fetch_csrf_token().await;
884
885 let resp = h
886 .client
887 .htmx_post_form(
888 "/join/step/account",
889 "username=jexisting&email=other@test.com&password=testpassword123",
890 )
891 .await;
892 assert!(resp.status.is_success());
893 assert!(
894 resp.text.contains("already taken"),
895 "Should show duplicate username error"
896 );
897 }
898
899 #[tokio::test]
900 async fn join_wizard_auth_required_after_step1() {
901 let mut h = TestHarness::new().await;
902
903 // Unauthenticated GET to step 2 should redirect/fail
904 let resp = h.client.get("/join/step/profile").await;
905 assert!(
906 resp.status.is_redirection() || resp.status == 401 || resp.status == 403,
907 "Unauthenticated step access should fail, got {}",
908 resp.status
909 );
910
911 // Unauthenticated POST to step 2 should redirect/fail
912 let resp = h
913 .client
914 .post_form("/join/step/profile", "display_name=test")
915 .await;
916 assert!(
917 resp.status.is_redirection() || resp.status == 401 || resp.status == 403,
918 "Unauthenticated step POST should fail, got {}",
919 resp.status
920 );
921 }
922
923 #[tokio::test]
924 async fn join_wizard_redirect_if_logged_in() {
925 let mut h = TestHarness::new().await;
926 h.signup("jloggedin", "jloggedin@test.com", "testpassword123")
927 .await;
928
929 // GET /join while logged in should redirect to /dashboard
930 let resp = h.client.get("/join").await;
931 assert!(
932 resp.status.is_redirection(),
933 "Logged-in user should be redirected, got {}",
934 resp.status
935 );
936 }
937
938 #[tokio::test]
939 async fn join_wizard_removed_steps_return_404() {
940 let mut h = TestHarness::new().await;
941 h.client.fetch_csrf_token().await;
942
943 // Create account
944 let resp = h
945 .client
946 .post_form(
947 "/join/step/account",
948 "username=jpitch&email=jpitch@test.com&password=testpassword123",
949 )
950 .await;
951 assert!(resp.status.is_success());
952
953 // Pitch and stripe steps no longer exist (removed in onboarding overhaul)
954 let resp = h.client.get("/join/step/pitch").await;
955 assert_eq!(resp.status, 404, "Pitch step should be removed");
956
957 let resp = h.client.get("/join/step/stripe").await;
958 assert_eq!(resp.status, 404, "Stripe step should be removed");
959 }
960
961 /// A monetization step with a VALID pricing change but an INVALID tier price
962 /// must persist nothing. Previously `update_project_pricing` committed before
963 /// the tier loop ran, so a malformed tier left the pricing change applied with
964 /// no tiers and the user on an error page (Run #11 UX MINOR, non-atomic step).
965 /// The handler now validates every tier row before any write.
966 #[tokio::test]
967 async fn project_wizard_monetization_invalid_tier_persists_nothing() {
968 let mut h = TestHarness::new().await;
969 h.create_creator("monatomic").await;
970
971 let resp = h
972 .client
973 .post_form(
974 "/dashboard/new-project/step/basics",
975 "title=Mon+Atomic&slug=mon-atomic&project_type=blog&description=x",
976 )
977 .await;
978 assert!(resp.status.is_success(), "basics failed: {}", resp.text);
979
980 let before: String =
981 sqlx::query_scalar("SELECT pricing_model FROM projects WHERE slug = 'mon-atomic'")
982 .fetch_one(&h.db)
983 .await
984 .unwrap();
985
986 // Valid pricing (buy_once $5) + an unparseable tier price.
987 let resp = h
988 .client
989 .post_form(
990 "/dashboard/new-project/mon-atomic/step/monetization",
991 "pricing_model=buy_once&price_dollars=5&tier_name_0=Gold&tier_price_0=notaprice",
992 )
993 .await;
994 assert!(
995 !resp.status.is_success(),
996 "an invalid tier price must reject the whole step: {} {}",
997 resp.status,
998 resp.text
999 );
1000
1001 let after: String =
1002 sqlx::query_scalar("SELECT pricing_model FROM projects WHERE slug = 'mon-atomic'")
1003 .fetch_one(&h.db)
1004 .await
1005 .unwrap();
1006 assert_eq!(
1007 after, before,
1008 "a rejected monetization step must not persist the pricing change"
1009 );
1010 assert_ne!(
1011 after, "buy_once",
1012 "the partial pricing write must have been prevented"
1013 );
1014
1015 let tier_count: i64 = sqlx::query_scalar(
1016 "SELECT COUNT(*) FROM subscription_tiers t JOIN projects p ON p.id = t.project_id WHERE p.slug = 'mon-atomic'",
1017 )
1018 .fetch_one(&h.db)
1019 .await
1020 .unwrap();
1021 assert_eq!(
1022 tier_count, 0,
1023 "no tiers should persist from a rejected step"
1024 );
1025 }
1026
1027 /// `save_appearance` validates the client-supplied cover URL against the CDN
1028 /// base. The check must use a path boundary (`{cdn_base}/`) so a host-prefix
1029 /// confusion (`cdn.makenot.work.attacker.com`) can't slip past (Run #12 UX fix).
1030 #[tokio::test]
1031 async fn project_wizard_appearance_rejects_non_cdn_cover_url() {
1032 let mut h = TestHarness::build(crate::harness::BuildOptions {
1033 cdn_base_url: Some("https://cdn.makenot.work".to_string()),
1034 ..Default::default()
1035 })
1036 .await;
1037 h.create_creator("cdncreator").await;
1038
1039 let resp = h
1040 .client
1041 .post_form(
1042 "/dashboard/new-project/step/basics",
1043 "title=CDN+Test&slug=cdn-test&project_type=blog&description=x",
1044 )
1045 .await;
1046 assert!(resp.status.is_success(), "basics failed: {}", resp.text);
1047
1048 // Host-prefix confusion: starts with the CDN base as a bare string prefix but
1049 // is a different host. Must be rejected.
1050 let resp = h
1051 .client
1052 .post_form(
1053 "/dashboard/new-project/cdn-test/step/appearance",
1054 "cover_image_url=https://cdn.makenot.work.attacker.com/x.jpg",
1055 )
1056 .await;
1057 assert!(
1058 resp.status.is_client_error(),
1059 "hostile cover URL must be rejected: {} {}",
1060 resp.status,
1061 resp.text
1062 );
1063 let stored: Option<String> =
1064 sqlx::query_scalar("SELECT cover_image_url FROM projects WHERE slug = 'cdn-test'")
1065 .fetch_one(&h.db)
1066 .await
1067 .unwrap();
1068 assert_eq!(stored, None, "hostile URL must not be persisted");
1069
1070 // A genuine CDN URL is accepted.
1071 let resp = h
1072 .client
1073 .post_form(
1074 "/dashboard/new-project/cdn-test/step/appearance",
1075 "cover_image_url=https://cdn.makenot.work/projects/abc/cover.jpg",
1076 )
1077 .await;
1078 assert!(
1079 resp.status.is_success(),
1080 "valid CDN cover URL should be accepted: {} {}",
1081 resp.status,
1082 resp.text
1083 );
1084 let stored: Option<String> =
1085 sqlx::query_scalar("SELECT cover_image_url FROM projects WHERE slug = 'cdn-test'")
1086 .fetch_one(&h.db)
1087 .await
1088 .unwrap();
1089 assert_eq!(
1090 stored.as_deref(),
1091 Some("https://cdn.makenot.work/projects/abc/cover.jpg")
1092 );
1093 }
1094
1095 // Join wizard, non-HTMX validation re-render preserves input (Run #22 UX LOW)
1096
1097 #[tokio::test]
1098 async fn join_account_non_htmx_validation_preserves_input() {
1099 // A non-HTMX (JS-disabled) account-step submit that fails validation must
1100 // re-render the wizard with the typed username/email preserved and the
1101 // offending field marked, not bounce to a generic error page that drops
1102 // everything. post_form sends no HX-Request header, so this is that path.
1103 let mut h = TestHarness::new().await;
1104
1105 let resp = h
1106 .client
1107 .post_form(
1108 "/join/step/account",
1109 "username=keepme&email=not-an-email&password=testpassword123",
1110 )
1111 .await;
1112
1113 assert!(
1114 resp.status.is_success(),
1115 "invalid non-HTMX submit should re-render the form (200), got {}: {}",
1116 resp.status,
1117 resp.text
1118 );
1119 // The typed username survives the round-trip (value attribute).
1120 assert!(
1121 resp.text.contains("value=\"keepme\""),
1122 "the typed username must be preserved in the re-render"
1123 );
1124 // The email field is flagged invalid and an error is shown.
1125 assert!(
1126 resp.text.contains("aria-invalid=\"true\""),
1127 "the offending field must be marked invalid"
1128 );
1129 assert!(
1130 resp.text.contains("valid email"),
1131 "the validation message must be shown, got: {}",
1132 resp.text
1133 );
1134 // No account was created.
1135 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'keepme'")
1136 .fetch_one(&h.db)
1137 .await
1138 .unwrap();
1139 assert_eq!(count, 0, "a failed validation must not create the account");
1140 }
1141