Skip to main content

max / makenotwork

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