Skip to main content

max / makenotwork

19.1 KB · 553 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.signup("fansub", "fansub@example.com", "password123").await;
36
37 // Seed a Fan+ subscription directly
38 sqlx::query(
39 r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
40 VALUES ($1, 'sub_test_123', 'cus_test_123', 'active', NOW() + interval '30 days')"#,
41 )
42 .bind(user_id)
43 .execute(&h.db)
44 .await
45 .unwrap();
46
47 let resp = h.client.get("/fan-plus").await;
48 assert_eq!(resp.status, 200);
49 assert!(resp.text.contains("membership is active"));
50 assert!(!resp.text.contains("Join Fan+"));
51 }
52
53 #[tokio::test]
54 async fn fan_plus_page_shows_success_banner() {
55 let mut h = TestHarness::new().await;
56 let user_id = h.signup("fanwelcome", "fanwelcome@example.com", "password123").await;
57
58 // Seed a Fan+ subscription
59 sqlx::query(
60 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_welcome', 'cus_welcome', 'active')",
61 )
62 .bind(user_id)
63 .execute(&h.db)
64 .await
65 .unwrap();
66
67 let resp = h.client.get("/fan-plus?subscribed=true").await;
68 assert_eq!(resp.status, 200);
69 assert!(resp.text.contains("Welcome"));
70 }
71
72 // ── DB operations (via raw SQL, since db::fan_plus is pub(crate)) ──
73
74 #[tokio::test]
75 async fn fan_plus_subscription_lifecycle() {
76 let mut h = TestHarness::new().await;
77 let user_id = h.signup("lifecycle", "lifecycle@example.com", "password123").await;
78
79 // Initially not active
80 let active: bool = sqlx::query_scalar(
81 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
82 )
83 .bind(user_id)
84 .fetch_one(&h.db)
85 .await
86 .unwrap();
87 assert!(!active);
88
89 // Create subscription
90 sqlx::query(
91 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id) VALUES ($1, 'sub_lc_1', 'cus_lc_1')",
92 )
93 .bind(user_id)
94 .execute(&h.db)
95 .await
96 .unwrap();
97
98 // Now active (default status is 'active')
99 let active: bool = sqlx::query_scalar(
100 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
101 )
102 .bind(user_id)
103 .fetch_one(&h.db)
104 .await
105 .unwrap();
106 assert!(active);
107
108 // Duplicate insert should fail (unique constraint on user_id)
109 let dup_result = sqlx::query(
110 "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",
111 )
112 .bind(user_id)
113 .fetch_optional(&h.db)
114 .await
115 .unwrap();
116 assert!(dup_result.is_none());
117
118 // Update status to past_due
119 sqlx::query("UPDATE fan_plus_subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_lc_1'")
120 .execute(&h.db)
121 .await
122 .unwrap();
123
124 // Past_due is not "active"
125 let active: bool = sqlx::query_scalar(
126 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
127 )
128 .bind(user_id)
129 .fetch_one(&h.db)
130 .await
131 .unwrap();
132 assert!(!active);
133
134 // Update period
135 sqlx::query(
136 "UPDATE fan_plus_subscriptions SET current_period_start = NOW(), current_period_end = NOW() + interval '30 days' WHERE stripe_subscription_id = 'sub_lc_1'",
137 )
138 .execute(&h.db)
139 .await
140 .unwrap();
141
142 let has_period: bool = sqlx::query_scalar(
143 "SELECT current_period_start IS NOT NULL FROM fan_plus_subscriptions WHERE stripe_subscription_id = 'sub_lc_1'",
144 )
145 .fetch_one(&h.db)
146 .await
147 .unwrap();
148 assert!(has_period);
149
150 // Cancel
151 sqlx::query(
152 "UPDATE fan_plus_subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = 'sub_lc_1'",
153 )
154 .execute(&h.db)
155 .await
156 .unwrap();
157
158 let status: String = sqlx::query_scalar(
159 "SELECT status FROM fan_plus_subscriptions WHERE user_id = $1",
160 )
161 .bind(user_id)
162 .fetch_one(&h.db)
163 .await
164 .unwrap();
165 assert_eq!(status, "canceled");
166 }
167
168 // ── Platform-wide promo codes ──
169
170 #[tokio::test]
171 async fn platform_promo_code_creation_and_lookup() {
172 let mut h = TestHarness::new().await;
173 let user_id = h.signup("promouser", "promo@example.com", "password123").await;
174
175 // Create a platform-wide promo code via SQL
176 sqlx::query(
177 r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
178 min_price_cents, max_uses, is_platform_wide)
179 VALUES ($1, 'FANCODE123', 'discount', 'fixed', 500, 0, 1, true)"#,
180 )
181 .bind(user_id)
182 .execute(&h.db)
183 .await
184 .unwrap();
185
186 // Look up by user and code (case-insensitive, platform-wide only)
187 let found: bool = sqlx::query_scalar(
188 "SELECT EXISTS(SELECT 1 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true)",
189 )
190 .bind(user_id)
191 .bind("fancode123")
192 .fetch_one(&h.db)
193 .await
194 .unwrap();
195 assert!(found);
196
197 // Non-platform codes should not match platform-wide lookup
198 sqlx::query(
199 r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents)
200 VALUES ($1, 'REGULAR123', 'discount', 'percentage', 10, 0)"#,
201 )
202 .bind(user_id)
203 .execute(&h.db)
204 .await
205 .unwrap();
206
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("REGULAR123")
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 assert!(!found);
216 }
217
218 #[tokio::test]
219 async fn platform_promo_code_accepted_at_checkout() {
220 let mut h = TestHarness::new().await;
221
222 // Create seller with a public item
223 let _seller_id = h.create_creator("seller").await;
224 let project: serde_json::Value = h.client.post_form("/api/projects", "slug=fan-proj&title=Fan+Project").await.json();
225 let project_id = project["id"].as_str().unwrap();
226 let item: serde_json::Value = h.client.post_form(
227 &format!("/api/projects/{}/items", project_id),
228 "title=Test+Item&price_cents=1000&item_type=digital",
229 ).await.json();
230 let item_id = item["id"].as_str().unwrap();
231 h.publish_project_and_item(project_id, item_id).await;
232
233 // Create buyer with a platform-wide promo code
234 h.client.post_form("/logout", "").await;
235 let buyer_id = h.signup("buyer", "buyer@example.com", "password123").await;
236
237 // Create the platform-wide promo code for the buyer via SQL
238 sqlx::query(
239 r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
240 min_price_cents, max_uses, is_platform_wide)
241 VALUES ($1, 'MYCODE', 'discount', 'fixed', 500, 0, 1, true)"#,
242 )
243 .bind(buyer_id)
244 .execute(&h.db)
245 .await
246 .unwrap();
247
248 // Attempt checkout with the platform promo code
249 // Without Stripe configured, the checkout will fail at "Creator hasn't set up payments yet"
250 // but the promo code validation should succeed (we won't see "Invalid promo code")
251 let resp = h.client.post_form(
252 &format!("/stripe/checkout/{}", item_id),
253 "promo_code=MYCODE",
254 ).await;
255
256 // The response should NOT be "Invalid promo code" — it should reach a later error
257 assert!(
258 !resp.text.contains("Invalid promo code"),
259 "Platform-wide promo code should be accepted at checkout, got: {}",
260 resp.text
261 );
262 }
263
264 #[tokio::test]
265 async fn platform_promo_code_makes_item_free() {
266 let mut h = TestHarness::new().await;
267
268 // Create seller with a $5 public item
269 let _seller_id = h.create_creator("freeseller").await;
270 let project: serde_json::Value = h.client.post_form("/api/projects", "slug=free-proj&title=FreeProject").await.json();
271 let project_id = project["id"].as_str().unwrap();
272 let item: serde_json::Value = h.client.post_form(
273 &format!("/api/projects/{}/items", project_id),
274 "title=Five+Dollar+Item&price_cents=500&item_type=digital",
275 ).await.json();
276 let item_id = item["id"].as_str().unwrap();
277 h.publish_project_and_item(project_id, item_id).await;
278
279 // Create buyer with a $5 platform-wide promo code (exactly matches price)
280 h.client.post_form("/logout", "").await;
281 let buyer_id = h.signup("freebuyer", "freebuyer@example.com", "password123").await;
282
283 sqlx::query(
284 r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
285 min_price_cents, max_uses, is_platform_wide)
286 VALUES ($1, 'FREECODE', 'discount', 'fixed', 500, 0, 1, true)"#,
287 )
288 .bind(buyer_id)
289 .execute(&h.db)
290 .await
291 .unwrap();
292
293 // Checkout with the promo code — $5 item with $5 discount = free claim
294 let resp = h.client.post_form(
295 &format!("/stripe/checkout/{}", item_id),
296 "promo_code=FREECODE",
297 ).await;
298
299 // Should redirect to library (free claim successful)
300 assert!(
301 resp.status == 303 || resp.status == 302 || resp.text.contains("purchase=success"),
302 "Free claim should redirect to library, got {} {}",
303 resp.status, resp.text
304 );
305
306 // Verify the promo code use count was incremented
307 let use_count: i32 = sqlx::query_scalar(
308 "SELECT use_count FROM promo_codes WHERE code = 'FREECODE'",
309 )
310 .fetch_one(&h.db)
311 .await
312 .unwrap();
313 assert_eq!(use_count, 1, "Promo code use_count should be incremented");
314 }
315
316 // ── Checkout guards ──
317
318 #[tokio::test]
319 async fn fan_plus_checkout_requires_login() {
320 let mut h = TestHarness::new().await;
321
322 let resp = h.client.post_form("/stripe/fan-plus", "").await;
323 // Should return 401 (not logged in)
324 assert_eq!(resp.status, 401, "Fan+ checkout should require login");
325 }
326
327 #[tokio::test]
328 async fn fan_plus_checkout_requires_stripe_config() {
329 let mut h = TestHarness::new().await;
330 h.signup("nofan", "nofan@example.com", "password123").await;
331
332 let resp = h.client.post_form("/stripe/fan-plus", "").await;
333 // Without Fan+ price ID configured, should get "not configured" error
334 assert!(
335 resp.status == 400 || resp.text.contains("not configured"),
336 "Should reject when Fan+ not configured, got {} {}",
337 resp.status, resp.text
338 );
339 }
340
341 #[tokio::test]
342 async fn fan_plus_checkout_redirects_existing_subscriber() {
343 let mut h = TestHarness::new().await;
344 let user_id = h.signup("already", "already@example.com", "password123").await;
345
346 // Seed an active Fan+ subscription
347 sqlx::query(
348 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_already', 'cus_already', 'active')",
349 )
350 .bind(user_id)
351 .execute(&h.db)
352 .await
353 .unwrap();
354
355 let resp = h.client.post_form("/stripe/fan-plus", "").await;
356 // Should redirect to /fan-plus (already subscribed) or get "not configured" before that check
357 // The order is: check config first, then check subscription
358 // Without Stripe/price config, it'll fail at "not configured" before the subscription check
359 assert!(
360 resp.status == 303 || resp.status == 302 || resp.status == 400,
361 "Should redirect or reject existing subscriber, got {}",
362 resp.status
363 );
364 }
365
366 // ── Session badge ──
367
368 #[tokio::test]
369 async fn session_reflects_fan_plus_status_after_login() {
370 let mut h = TestHarness::new().await;
371 let user_id = h.signup("badgeuser", "badge@example.com", "password123").await;
372
373 // Seed a Fan+ subscription
374 sqlx::query(
375 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_badge', 'cus_badge', 'active')",
376 )
377 .bind(user_id)
378 .execute(&h.db)
379 .await
380 .unwrap();
381
382 // Re-login to pick up Fan+ status in session
383 h.client.post_form("/logout", "").await;
384 h.login("badgeuser", "password123").await;
385
386 // The fan-plus page should show active status (confirming session has is_fan_plus)
387 let resp = h.client.get("/fan-plus").await;
388 assert_eq!(resp.status, 200);
389 assert!(resp.text.contains("membership is active"));
390 }
391
392 #[tokio::test]
393 async fn canceled_fan_plus_not_shown_as_active() {
394 let mut h = TestHarness::new().await;
395 let user_id = h.signup("canceled", "canceled@example.com", "password123").await;
396
397 // Seed a canceled Fan+ subscription
398 sqlx::query(
399 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) VALUES ($1, 'sub_canceled', 'cus_canceled', 'canceled', NOW())",
400 )
401 .bind(user_id)
402 .execute(&h.db)
403 .await
404 .unwrap();
405
406 let resp = h.client.get("/fan-plus").await;
407 assert_eq!(resp.status, 200);
408 // Should show subscribe button, not active status
409 assert!(resp.text.contains("Join Fan+"));
410 assert!(!resp.text.contains("membership is active"));
411 }
412
413 // ── Self-service cancel / resume / billing portal ──
414 //
415 // These routes underpin the small dashboard pane added in this step. The
416 // MockPaymentProvider ack's the Stripe calls so we only need to check our DB
417 // state and HTTP responses.
418
419 async fn seed_active_fan_plus(h: &TestHarness, user_id: makenotwork::db::UserId, sub_id: &str) {
420 sqlx::query(
421 "INSERT INTO fan_plus_subscriptions \
422 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
423 VALUES ($1, $2, $3, 'active', NOW() + interval '30 days')",
424 )
425 .bind(user_id)
426 .bind(sub_id)
427 .bind(format!("cus_{sub_id}"))
428 .execute(&h.db)
429 .await
430 .unwrap();
431 }
432
433 #[tokio::test]
434 async fn fan_plus_cancel_sets_cancel_at_period_end() {
435 let mut h = TestHarness::with_mocks().await;
436 let user_id = h.signup("cancuser", "canc@example.com", "password123").await;
437 seed_active_fan_plus(&h, user_id, "sub_cancel_1").await;
438
439 h.client.get("/dashboard").await; // prime CSRF
440 let csrf = h.client.csrf_token().expect("csrf").to_string();
441 let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await;
442 assert!(resp.status.is_redirection(), "status: {} body: {}", resp.status, resp.text);
443
444 let pending: bool = sqlx::query_scalar(
445 "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
446 )
447 .bind(user_id)
448 .fetch_one(&h.db)
449 .await
450 .unwrap();
451 assert!(pending);
452 }
453
454 #[tokio::test]
455 async fn fan_plus_resume_clears_cancel_flag() {
456 let mut h = TestHarness::with_mocks().await;
457 let user_id = h.signup("resuuser", "resu@example.com", "password123").await;
458 sqlx::query(
459 "INSERT INTO fan_plus_subscriptions \
460 (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end) \
461 VALUES ($1, 'sub_resume_1', 'cus_resume_1', 'active', TRUE)",
462 )
463 .bind(user_id)
464 .execute(&h.db)
465 .await
466 .unwrap();
467
468 h.client.get("/dashboard").await;
469 let csrf = h.client.csrf_token().expect("csrf").to_string();
470 let resp = h.client.post_form("/stripe/fan-plus/resume", &format!("_csrf={csrf}")).await;
471 assert!(resp.status.is_redirection(), "status: {}", resp.status);
472
473 let pending: bool = sqlx::query_scalar(
474 "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
475 )
476 .bind(user_id)
477 .fetch_one(&h.db)
478 .await
479 .unwrap();
480 assert!(!pending);
481 }
482
483 #[tokio::test]
484 async fn fan_plus_cancel_requires_active_subscription() {
485 let mut h = TestHarness::with_mocks().await;
486 h.signup("nosub", "nosub@example.com", "password123").await;
487
488 h.client.get("/dashboard").await;
489 let csrf = h.client.csrf_token().expect("csrf").to_string();
490 let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await;
491 // BadRequest from "No active Fan+ subscription"
492 assert_eq!(resp.status.as_u16(), 400);
493 }
494
495 #[tokio::test]
496 async fn billing_portal_redirects_to_stripe() {
497 let mut h = TestHarness::with_mocks().await;
498 let user_id = h.signup("portaluser", "portal@example.com", "password123").await;
499 seed_active_fan_plus(&h, user_id, "sub_portal_1").await;
500
501 h.client.get("/dashboard").await;
502 let csrf = h.client.csrf_token().expect("csrf").to_string();
503 let resp = h.client.post_form("/stripe/billing-portal", &format!("_csrf={csrf}")).await;
504 assert!(resp.status.is_redirection(), "status: {}", resp.status);
505 let location = resp.header("location").expect("Location header");
506 assert!(location.starts_with("https://billing.stripe.test/portal"));
507 }
508
509 #[tokio::test]
510 async fn dashboard_account_tab_shows_fan_plus_pane_for_subscriber() {
511 let mut h = TestHarness::new().await;
512 let user_id = h.signup("paneuser", "pane@example.com", "password123").await;
513 seed_active_fan_plus(&h, user_id, "sub_pane_1").await;
514
515 let resp = h.client.get("/dashboard/tabs/account").await;
516 assert_eq!(resp.status, 200);
517 assert!(resp.text.contains("Fan+ membership"));
518 assert!(resp.text.contains("Cancel"));
519 assert!(resp.text.contains("Manage billing"));
520 assert!(!resp.text.contains("Learn about Fan+"));
521 }
522
523 #[tokio::test]
524 async fn dashboard_account_tab_shows_resume_when_cancel_pending() {
525 let mut h = TestHarness::new().await;
526 let user_id = h.signup("pendinguser", "pending@example.com", "password123").await;
527 sqlx::query(
528 "INSERT INTO fan_plus_subscriptions \
529 (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end, current_period_end) \
530 VALUES ($1, 'sub_pending_1', 'cus_pending_1', 'active', TRUE, NOW() + interval '15 days')",
531 )
532 .bind(user_id)
533 .execute(&h.db)
534 .await
535 .unwrap();
536
537 let resp = h.client.get("/dashboard/tabs/account").await;
538 assert_eq!(resp.status, 200);
539 assert!(resp.text.contains("Cancellation scheduled"));
540 assert!(resp.text.contains("Resume"));
541 }
542
543 #[tokio::test]
544 async fn dashboard_account_tab_shows_upsell_when_not_subscribed() {
545 let mut h = TestHarness::new().await;
546 h.signup("notsub", "notsub@example.com", "password123").await;
547
548 let resp = h.client.get("/dashboard/tabs/account").await;
549 assert_eq!(resp.status, 200);
550 assert!(resp.text.contains("Learn about Fan+"));
551 assert!(!resp.text.contains("Manage billing"));
552 }
553