Skip to main content

max / makenotwork

23.1 KB · 711 lines History Blame Raw
1 //! Fan+ subscription integration tests.
2 //!
3 //! Tests the Fan+ consumer subscription feature: page rendering, DB-level
4 //! subscription management, platform-wide promo codes, and checkout guards.
5
6 use crate::harness::TestHarness;
7
8 // ── Page rendering ──
9
10 #[tokio::test]
11 async fn fan_plus_page_renders_for_anonymous() {
12 let mut h = TestHarness::new().await;
13
14 let resp = h.client.get("/fan-plus").await;
15 assert_eq!(resp.status, 200);
16 assert!(resp.text.contains("Fan+"));
17 assert!(resp.text.contains("Log in"));
18 assert!(!resp.text.contains("Join Fan+"));
19 }
20
21 #[tokio::test]
22 async fn fan_plus_page_renders_subscribe_button_for_user() {
23 let mut h = TestHarness::new().await;
24 h.signup("fanuser", "fan@example.com", "password123").await;
25
26 let resp = h.client.get("/fan-plus").await;
27 assert_eq!(resp.status, 200);
28 assert!(resp.text.contains("Join Fan+"));
29 assert!(!resp.text.contains("membership is active"));
30 }
31
32 #[tokio::test]
33 async fn fan_plus_page_shows_active_status_for_subscriber() {
34 let mut h = TestHarness::new().await;
35 let user_id = h
36 .signup("fansub", "fansub@example.com", "password123")
37 .await;
38
39 // Seed a Fan+ subscription directly
40 sqlx::query(
41 r"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
42 VALUES ($1, 'sub_test_123', 'cus_test_123', 'active', NOW() + interval '30 days')",
43 )
44 .bind(user_id)
45 .execute(&h.db)
46 .await
47 .unwrap();
48
49 let resp = h.client.get("/fan-plus").await;
50 assert_eq!(resp.status, 200);
51 assert!(resp.text.contains("membership is active"));
52 assert!(!resp.text.contains("Join Fan+"));
53 }
54
55 #[tokio::test]
56 async fn fan_plus_page_shows_success_banner() {
57 let mut h = TestHarness::new().await;
58 let user_id = h
59 .signup("fanwelcome", "fanwelcome@example.com", "password123")
60 .await;
61
62 // Seed a Fan+ subscription
63 sqlx::query(
64 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_welcome', 'cus_welcome', 'active')",
65 )
66 .bind(user_id)
67 .execute(&h.db)
68 .await
69 .unwrap();
70
71 let resp = h.client.get("/fan-plus?subscribed=true").await;
72 assert_eq!(resp.status, 200);
73 assert!(resp.text.contains("Welcome"));
74 }
75
76 // ── DB operations (via raw SQL, since db::fan_plus is pub(crate)) ──
77
78 #[tokio::test]
79 async fn fan_plus_subscription_lifecycle() {
80 let mut h = TestHarness::new().await;
81 let user_id = h
82 .signup("lifecycle", "lifecycle@example.com", "password123")
83 .await;
84
85 // Initially not active
86 let active: bool = sqlx::query_scalar(
87 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
88 )
89 .bind(user_id)
90 .fetch_one(&h.db)
91 .await
92 .unwrap();
93 assert!(!active);
94
95 // Create subscription
96 sqlx::query(
97 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id) VALUES ($1, 'sub_lc_1', 'cus_lc_1')",
98 )
99 .bind(user_id)
100 .execute(&h.db)
101 .await
102 .unwrap();
103
104 // Now active (default status is 'active')
105 let active: bool = sqlx::query_scalar(
106 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
107 )
108 .bind(user_id)
109 .fetch_one(&h.db)
110 .await
111 .unwrap();
112 assert!(active);
113
114 // Duplicate insert should fail (unique constraint on user_id)
115 let dup_result = sqlx::query(
116 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id) VALUES ($1, 'sub_lc_2', 'cus_lc_2') ON CONFLICT (user_id) DO NOTHING RETURNING id",
117 )
118 .bind(user_id)
119 .fetch_optional(&h.db)
120 .await
121 .unwrap();
122 assert!(dup_result.is_none());
123
124 sqlx::query("UPDATE fan_plus_subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_lc_1'")
125 .execute(&h.db)
126 .await
127 .unwrap();
128
129 // Past_due is not "active"
130 let active: bool = sqlx::query_scalar(
131 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
132 )
133 .bind(user_id)
134 .fetch_one(&h.db)
135 .await
136 .unwrap();
137 assert!(!active);
138
139 // Update period
140 sqlx::query(
141 "UPDATE fan_plus_subscriptions SET current_period_start = NOW(), current_period_end = NOW() + interval '30 days' WHERE stripe_subscription_id = 'sub_lc_1'",
142 )
143 .execute(&h.db)
144 .await
145 .unwrap();
146
147 let has_period: bool = sqlx::query_scalar(
148 "SELECT current_period_start IS NOT NULL FROM fan_plus_subscriptions WHERE stripe_subscription_id = 'sub_lc_1'",
149 )
150 .fetch_one(&h.db)
151 .await
152 .unwrap();
153 assert!(has_period);
154
155 // Cancel
156 sqlx::query(
157 "UPDATE fan_plus_subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = 'sub_lc_1'",
158 )
159 .execute(&h.db)
160 .await
161 .unwrap();
162
163 let status: String =
164 sqlx::query_scalar("SELECT status FROM fan_plus_subscriptions WHERE user_id = $1")
165 .bind(user_id)
166 .fetch_one(&h.db)
167 .await
168 .unwrap();
169 assert_eq!(status, "canceled");
170 }
171
172 // ── Platform-wide promo codes ──
173
174 #[tokio::test]
175 async fn platform_promo_code_creation_and_lookup() {
176 let mut h = TestHarness::new().await;
177 let user_id = h
178 .signup("promouser", "promo@example.com", "password123")
179 .await;
180
181 // Create a platform-wide promo code via SQL
182 sqlx::query(
183 r"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
184 min_price_cents, max_uses, is_platform_wide)
185 VALUES ($1, 'FANCODE123', 'discount', 'fixed', 500, 0, 1, true)",
186 )
187 .bind(user_id)
188 .execute(&h.db)
189 .await
190 .unwrap();
191
192 // Look up by user and code (case-insensitive, platform-wide only)
193 let found: bool = sqlx::query_scalar(
194 "SELECT EXISTS(SELECT 1 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true)",
195 )
196 .bind(user_id)
197 .bind("fancode123")
198 .fetch_one(&h.db)
199 .await
200 .unwrap();
201 assert!(found);
202
203 // Non-platform codes should not match platform-wide lookup
204 sqlx::query(
205 r"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents)
206 VALUES ($1, 'REGULAR123', 'discount', 'percentage', 10, 0)",
207 )
208 .bind(user_id)
209 .execute(&h.db)
210 .await
211 .unwrap();
212
213 let found: bool = sqlx::query_scalar(
214 "SELECT EXISTS(SELECT 1 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true)",
215 )
216 .bind(user_id)
217 .bind("REGULAR123")
218 .fetch_one(&h.db)
219 .await
220 .unwrap();
221 assert!(!found);
222 }
223
224 #[tokio::test]
225 async fn platform_promo_code_accepted_at_checkout() {
226 let mut h = TestHarness::new().await;
227
228 // Create seller with a public item
229 let _seller_id = h.create_creator("seller").await;
230 let project: serde_json::Value = h
231 .client
232 .post_form("/api/projects", "slug=fan-proj&title=Fan+Project")
233 .await
234 .json();
235 let project_id = project["id"].as_str().unwrap();
236 let item: serde_json::Value = h
237 .client
238 .post_form(
239 &format!("/api/projects/{project_id}/items"),
240 "title=Test+Item&price_cents=1000&item_type=digital",
241 )
242 .await
243 .json();
244 let item_id = item["id"].as_str().unwrap();
245 h.publish_project_and_item(project_id, item_id).await;
246
247 // Create buyer with a platform-wide promo code
248 h.client.post_form("/logout", "").await;
249 let buyer_id = h.signup("buyer", "buyer@example.com", "password123").await;
250
251 // Create the platform-wide promo code for the buyer via SQL
252 sqlx::query(
253 r"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
254 min_price_cents, max_uses, is_platform_wide)
255 VALUES ($1, 'MYCODE', 'discount', 'fixed', 500, 0, 1, true)",
256 )
257 .bind(buyer_id)
258 .execute(&h.db)
259 .await
260 .unwrap();
261
262 // Attempt checkout with the platform promo code
263 // Without Stripe configured, the checkout will fail at "Creator hasn't set up payments yet"
264 // but the promo code validation should succeed (we won't see "Invalid promo code")
265 let resp = h
266 .client
267 .post_form(&format!("/stripe/checkout/{item_id}"), "promo_code=MYCODE")
268 .await;
269
270 // The response should NOT be "Invalid promo code", it should reach a later error
271 assert!(
272 !resp.text.contains("Invalid promo code"),
273 "Platform-wide promo code should be accepted at checkout, got: {}",
274 resp.text
275 );
276 }
277
278 #[tokio::test]
279 async fn platform_promo_code_makes_item_free() {
280 let mut h = TestHarness::new().await;
281
282 // Create seller with a $5 public item
283 let _seller_id = h.create_creator("freeseller").await;
284 let project: serde_json::Value = h
285 .client
286 .post_form("/api/projects", "slug=free-proj&title=FreeProject")
287 .await
288 .json();
289 let project_id = project["id"].as_str().unwrap();
290 let item: serde_json::Value = h
291 .client
292 .post_form(
293 &format!("/api/projects/{project_id}/items"),
294 "title=Five+Dollar+Item&price_cents=500&item_type=digital",
295 )
296 .await
297 .json();
298 let item_id = item["id"].as_str().unwrap();
299 h.publish_project_and_item(project_id, item_id).await;
300
301 // Create buyer with a $5 platform-wide promo code (exactly matches price)
302 h.client.post_form("/logout", "").await;
303 let buyer_id = h
304 .signup("freebuyer", "freebuyer@example.com", "password123")
305 .await;
306
307 sqlx::query(
308 r"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
309 min_price_cents, max_uses, is_platform_wide)
310 VALUES ($1, 'FREECODE', 'discount', 'fixed', 500, 0, 1, true)",
311 )
312 .bind(buyer_id)
313 .execute(&h.db)
314 .await
315 .unwrap();
316
317 // Checkout with the promo code, $5 item with $5 discount = free claim
318 let resp = h
319 .client
320 .post_form(
321 &format!("/stripe/checkout/{item_id}"),
322 "promo_code=FREECODE",
323 )
324 .await;
325
326 // Should redirect to library (free claim successful)
327 assert!(
328 resp.status == 303 || resp.status == 302 || resp.text.contains("purchase=success"),
329 "Free claim should redirect to library, got {} {}",
330 resp.status,
331 resp.text
332 );
333
334 // Verify the promo code use count was incremented
335 let use_count: i32 =
336 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'FREECODE'")
337 .fetch_one(&h.db)
338 .await
339 .unwrap();
340 assert_eq!(use_count, 1, "Promo code use_count should be incremented");
341
342 // The creator must be made whole: a platform-funded (Fan+) credit that made the
343 // $5 item free records a $5 reimbursement obligation on the transaction, so the
344 // scheduler transfers it to the seller (MNW funds it, not the creator).
345 let credit_cents: i64 = sqlx::query_scalar(
346 "SELECT platform_credit_cents FROM transactions WHERE item_id = $1::uuid AND status = 'completed'",
347 )
348 .bind(item_id)
349 .fetch_one(&h.db)
350 .await
351 .unwrap();
352 assert_eq!(
353 credit_cents, 500,
354 "free-via-Fan+-credit claim must owe the creator the item price"
355 );
356 }
357
358 /// A completed transaction owing a platform credit is claimed and settled exactly
359 /// once, a replayed settlement sweep does not transfer twice (Run 12 Payments
360 /// SERIOUS: MNW funds the Fan+ credit, creator made whole).
361 #[tokio::test]
362 async fn platform_credit_claimed_and_settled_once() {
363 use makenotwork::db;
364
365 let mut h = TestHarness::new().await;
366 let seller_id = h.create_creator("creditseller").await;
367 let project: serde_json::Value = h
368 .client
369 .post_form("/api/projects", "slug=cred-proj&title=CredProject")
370 .await
371 .json();
372 let project_id = project["id"].as_str().unwrap();
373 let item: serde_json::Value = h
374 .client
375 .post_form(
376 &format!("/api/projects/{project_id}/items"),
377 "title=Item&price_cents=2000&item_type=digital",
378 )
379 .await
380 .json();
381 let item_id = item["id"].as_str().unwrap();
382 h.publish_project_and_item(project_id, item_id).await;
383
384 let buyer_id = h
385 .signup("credbuyer", "credbuyer@example.com", "password123")
386 .await;
387
388 // A completed sale that owes the creator a $5 platform-funded credit.
389 sqlx::query(
390 r"INSERT INTO transactions
391 (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
392 stripe_checkout_session_id, status, completed_at, item_title, seller_username,
393 share_contact, platform_credit_cents)
394 VALUES ($1, $2, $3::uuid, 1500, 0, 'cs_credit_once', 'completed', NOW(),
395 'Item', 'creditseller', false, 500)",
396 )
397 .bind(buyer_id)
398 .bind(seller_id)
399 .bind(item_id)
400 .execute(&h.db)
401 .await
402 .unwrap();
403
404 // First claim yields the owed credit for the seller.
405 let claimed = db::platform_credits::claim_unsettled_credit(&h.db)
406 .await
407 .unwrap();
408 let claimed = claimed.expect("an unsettled platform credit should be claimable");
409 assert_eq!(claimed.seller_id, seller_id);
410 assert_eq!(claimed.amount_cents.as_i64(), 500);
411
412 // While claimed-but-unsettled it is not re-claimable (no double transfer).
413 let second = db::platform_credits::claim_unsettled_credit(&h.db)
414 .await
415 .unwrap();
416 assert!(
417 second.is_none(),
418 "a claimed-but-unsettled credit must not be re-claimed"
419 );
420
421 // After settlement it stays unclaimable (settled once).
422 db::platform_credits::mark_settled(&h.db, claimed.transaction_id, "tr_test_credit")
423 .await
424 .unwrap();
425 let after_settle = db::platform_credits::claim_unsettled_credit(&h.db)
426 .await
427 .unwrap();
428 assert!(
429 after_settle.is_none(),
430 "a settled credit must never be claimed again"
431 );
432 }
433
434 // ── Checkout guards ──
435
436 #[tokio::test]
437 async fn fan_plus_checkout_requires_login() {
438 let mut h = TestHarness::new().await;
439
440 // Establish a CSRF token first so the POST clears the CSRF gate and reaches
441 // the auth check, otherwise the posture-independent CSRF origin gate
442 // rejects the cold POST with 403 before login is ever evaluated.
443 h.client.fetch_csrf_token().await;
444
445 let resp = h.client.post_form("/stripe/fan-plus", "").await;
446 // Should return 401 (not logged in)
447 assert_eq!(resp.status, 401, "Fan+ checkout should require login");
448 }
449
450 #[tokio::test]
451 async fn fan_plus_checkout_requires_stripe_config() {
452 let mut h = TestHarness::new().await;
453 h.signup("nofan", "nofan@example.com", "password123").await;
454
455 let resp = h.client.post_form("/stripe/fan-plus", "").await;
456 // Without Fan+ price ID configured, should get "not configured" error
457 assert!(
458 resp.status == 400 || resp.text.contains("not configured"),
459 "Should reject when Fan+ not configured, got {} {}",
460 resp.status,
461 resp.text
462 );
463 }
464
465 #[tokio::test]
466 async fn fan_plus_checkout_redirects_existing_subscriber() {
467 let mut h = TestHarness::new().await;
468 let user_id = h
469 .signup("already", "already@example.com", "password123")
470 .await;
471
472 // Seed an active Fan+ subscription
473 sqlx::query(
474 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_already', 'cus_already', 'active')",
475 )
476 .bind(user_id)
477 .execute(&h.db)
478 .await
479 .unwrap();
480
481 let resp = h.client.post_form("/stripe/fan-plus", "").await;
482 // Should redirect to /fan-plus (already subscribed) or get "not configured" before that check
483 // The order is: check config first, then check subscription
484 // Without Stripe/price config, it'll fail at "not configured" before the subscription check
485 assert!(
486 resp.status == 303 || resp.status == 302 || resp.status == 400,
487 "Should redirect or reject existing subscriber, got {}",
488 resp.status
489 );
490 }
491
492 // ── Session badge ──
493
494 #[tokio::test]
495 async fn session_reflects_fan_plus_status_after_login() {
496 let mut h = TestHarness::new().await;
497 let user_id = h
498 .signup("badgeuser", "badge@example.com", "password123")
499 .await;
500
501 // Seed a Fan+ subscription
502 sqlx::query(
503 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_badge', 'cus_badge', 'active')",
504 )
505 .bind(user_id)
506 .execute(&h.db)
507 .await
508 .unwrap();
509
510 // Re-login to pick up Fan+ status in session
511 h.client.post_form("/logout", "").await;
512 h.login("badgeuser", "password123").await;
513
514 // The fan-plus page should show active status (confirming session has is_fan_plus)
515 let resp = h.client.get("/fan-plus").await;
516 assert_eq!(resp.status, 200);
517 assert!(resp.text.contains("membership is active"));
518 }
519
520 #[tokio::test]
521 async fn canceled_fan_plus_not_shown_as_active() {
522 let mut h = TestHarness::new().await;
523 let user_id = h
524 .signup("canceled", "canceled@example.com", "password123")
525 .await;
526
527 // Seed a canceled Fan+ subscription
528 sqlx::query(
529 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) VALUES ($1, 'sub_canceled', 'cus_canceled', 'canceled', NOW())",
530 )
531 .bind(user_id)
532 .execute(&h.db)
533 .await
534 .unwrap();
535
536 let resp = h.client.get("/fan-plus").await;
537 assert_eq!(resp.status, 200);
538 // Should show subscribe button, not active status
539 assert!(resp.text.contains("Join Fan+"));
540 assert!(!resp.text.contains("membership is active"));
541 }
542
543 // ── Self-service cancel / resume / billing portal ──
544 //
545 // These routes underpin the small dashboard pane added in this step. The
546 // MockPaymentProvider ack's the Stripe calls so we only need to check our DB
547 // state and HTTP responses.
548
549 async fn seed_active_fan_plus(h: &TestHarness, user_id: makenotwork::db::UserId, sub_id: &str) {
550 sqlx::query(
551 "INSERT INTO fan_plus_subscriptions \
552 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
553 VALUES ($1, $2, $3, 'active', NOW() + interval '30 days')",
554 )
555 .bind(user_id)
556 .bind(sub_id)
557 .bind(format!("cus_{sub_id}"))
558 .execute(&h.db)
559 .await
560 .unwrap();
561 }
562
563 #[tokio::test]
564 async fn fan_plus_cancel_sets_cancel_at_period_end() {
565 let mut h = TestHarness::with_mocks().await;
566 let user_id = h
567 .signup("cancuser", "canc@example.com", "password123")
568 .await;
569 seed_active_fan_plus(&h, user_id, "sub_cancel_1").await;
570
571 h.client.get("/dashboard").await; // prime CSRF
572 let csrf = h.client.csrf_token().expect("csrf").to_string();
573 let resp = h
574 .client
575 .post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}"))
576 .await;
577 assert!(
578 resp.status.is_redirection(),
579 "status: {} body: {}",
580 resp.status,
581 resp.text
582 );
583
584 let pending: bool = sqlx::query_scalar(
585 "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
586 )
587 .bind(user_id)
588 .fetch_one(&h.db)
589 .await
590 .unwrap();
591 assert!(pending);
592 }
593
594 #[tokio::test]
595 async fn fan_plus_resume_clears_cancel_flag() {
596 let mut h = TestHarness::with_mocks().await;
597 let user_id = h
598 .signup("resuuser", "resu@example.com", "password123")
599 .await;
600 sqlx::query(
601 "INSERT INTO fan_plus_subscriptions \
602 (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end) \
603 VALUES ($1, 'sub_resume_1', 'cus_resume_1', 'active', TRUE)",
604 )
605 .bind(user_id)
606 .execute(&h.db)
607 .await
608 .unwrap();
609
610 h.client.get("/dashboard").await;
611 let csrf = h.client.csrf_token().expect("csrf").to_string();
612 let resp = h
613 .client
614 .post_form("/stripe/fan-plus/resume", &format!("_csrf={csrf}"))
615 .await;
616 assert!(resp.status.is_redirection(), "status: {}", resp.status);
617
618 let pending: bool = sqlx::query_scalar(
619 "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
620 )
621 .bind(user_id)
622 .fetch_one(&h.db)
623 .await
624 .unwrap();
625 assert!(!pending);
626 }
627
628 #[tokio::test]
629 async fn fan_plus_cancel_requires_active_subscription() {
630 let mut h = TestHarness::with_mocks().await;
631 h.signup("nosub", "nosub@example.com", "password123").await;
632
633 h.client.get("/dashboard").await;
634 let csrf = h.client.csrf_token().expect("csrf").to_string();
635 let resp = h
636 .client
637 .post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}"))
638 .await;
639 // BadRequest from "No active Fan+ subscription"
640 assert_eq!(resp.status.as_u16(), 400);
641 }
642
643 #[tokio::test]
644 async fn billing_portal_redirects_to_stripe() {
645 let mut h = TestHarness::with_mocks().await;
646 let user_id = h
647 .signup("portaluser", "portal@example.com", "password123")
648 .await;
649 seed_active_fan_plus(&h, user_id, "sub_portal_1").await;
650
651 h.client.get("/dashboard").await;
652 let csrf = h.client.csrf_token().expect("csrf").to_string();
653 let resp = h
654 .client
655 .post_form("/stripe/billing-portal", &format!("_csrf={csrf}"))
656 .await;
657 assert!(resp.status.is_redirection(), "status: {}", resp.status);
658 let location = resp.header("location").expect("Location header");
659 assert!(location.starts_with("https://billing.stripe.test/portal"));
660 }
661
662 #[tokio::test]
663 async fn dashboard_account_tab_shows_fan_plus_pane_for_subscriber() {
664 let mut h = TestHarness::new().await;
665 let user_id = h
666 .signup("paneuser", "pane@example.com", "password123")
667 .await;
668 seed_active_fan_plus(&h, user_id, "sub_pane_1").await;
669
670 let resp = h.client.get("/dashboard/tabs/account").await;
671 assert_eq!(resp.status, 200);
672 assert!(resp.text.contains("Fan+ membership"));
673 assert!(resp.text.contains("Cancel"));
674 assert!(resp.text.contains("Manage billing"));
675 assert!(!resp.text.contains("Learn about Fan+"));
676 }
677
678 #[tokio::test]
679 async fn dashboard_account_tab_shows_resume_when_cancel_pending() {
680 let mut h = TestHarness::new().await;
681 let user_id = h
682 .signup("pendinguser", "pending@example.com", "password123")
683 .await;
684 sqlx::query(
685 "INSERT INTO fan_plus_subscriptions \
686 (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end, current_period_end) \
687 VALUES ($1, 'sub_pending_1', 'cus_pending_1', 'active', TRUE, NOW() + interval '15 days')",
688 )
689 .bind(user_id)
690 .execute(&h.db)
691 .await
692 .unwrap();
693
694 let resp = h.client.get("/dashboard/tabs/account").await;
695 assert_eq!(resp.status, 200);
696 assert!(resp.text.contains("Cancellation scheduled"));
697 assert!(resp.text.contains("Resume"));
698 }
699
700 #[tokio::test]
701 async fn dashboard_account_tab_shows_upsell_when_not_subscribed() {
702 let mut h = TestHarness::new().await;
703 h.signup("notsub", "notsub@example.com", "password123")
704 .await;
705
706 let resp = h.client.get("/dashboard/tabs/account").await;
707 assert_eq!(resp.status, 200);
708 assert!(resp.text.contains("Learn about Fan+"));
709 assert!(!resp.text.contains("Manage billing"));
710 }
711