Skip to main content

max / makenotwork

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