Skip to main content

max / makenotwork

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