Skip to main content

max / makenotwork

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