Skip to main content

max / makenotwork

28.7 KB · 894 lines History Blame Raw
1 //! Lifecycle integration tests: full state machine traversals and concurrent access.
2 //!
3 //! These tests verify multi-step workflows that span creation, mutation, and cleanup,
4 //! plus concurrent access scenarios that exercise database atomicity guarantees.
5
6 use crate::harness::TestHarness;
7 use makenotwork::db::UserId;
8 use serde_json::Value;
9
10 // =============================================================================
11 // Sandbox lifecycle: create → use features → expire → cleanup deletes account
12 // =============================================================================
13
14 /// Helper: create a sandbox account and return its user_id.
15 async fn create_sandbox_get_id(h: &mut TestHarness) -> UserId {
16 let resp = h.client.get("/sandbox").await;
17 assert!(resp.status.is_success());
18 let resp = h.client.post_form("/sandbox", "").await;
19 assert!(
20 resp.status.is_redirection(),
21 "POST /sandbox should redirect, got {}",
22 resp.status
23 );
24
25 sqlx::query_scalar::<_, UserId>(
26 "SELECT id FROM users WHERE is_sandbox = TRUE ORDER BY created_at DESC LIMIT 1",
27 )
28 .fetch_one(&h.db)
29 .await
30 .expect("No sandbox user found")
31 }
32
33 #[tokio::test]
34 async fn sandbox_lifecycle_create_use_expire_cleanup() {
35 let mut h = TestHarness::new().await;
36
37 // Step 1: Create sandbox
38 let user_id = create_sandbox_get_id(&mut h).await;
39
40 // Refresh CSRF token after sandbox creation (session rotated on login)
41 h.client.fetch_csrf_token().await;
42
43 // Step 2: Use features — create a project and item
44 let resp = h
45 .client
46 .post_form("/api/projects", "slug=sb-life&title=Sandbox+Life")
47 .await;
48 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
49 let project: Value = resp.json();
50 let project_id = project["id"].as_str().unwrap().to_string();
51
52 let resp = h
53 .client
54 .post_form(
55 &format!("/api/projects/{}/items", project_id),
56 "title=Sandbox+Item&item_type=digital&price_cents=0",
57 )
58 .await;
59 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
60
61 // Verify content exists
62 let item_count: i64 = sqlx::query_scalar(
63 "SELECT COUNT(*) FROM items WHERE project_id = $1::uuid",
64 )
65 .bind(&project_id)
66 .fetch_one(&h.db)
67 .await
68 .unwrap();
69 assert_eq!(item_count, 1, "Should have 1 item");
70
71 // Step 3: Simulate expiry by backdating sandbox_expires_at
72 sqlx::query("UPDATE users SET sandbox_expires_at = NOW() - INTERVAL '1 hour' WHERE id = $1")
73 .bind(user_id)
74 .execute(&h.db)
75 .await
76 .unwrap();
77
78 // Verify user is now in expired set
79 let expired_ids: Vec<UserId> = sqlx::query_scalar(
80 "SELECT id FROM users WHERE is_sandbox = TRUE AND sandbox_expires_at < NOW()",
81 )
82 .fetch_all(&h.db)
83 .await
84 .unwrap();
85 assert!(
86 expired_ids.contains(&user_id),
87 "Sandbox user should appear in expired set"
88 );
89
90 // Step 4: Simulate cleanup (direct SQL CASCADE delete, same as scheduler does)
91 sqlx::query("DELETE FROM users WHERE id = $1")
92 .bind(user_id)
93 .execute(&h.db)
94 .await
95 .unwrap();
96
97 // Verify everything is gone
98 let user_exists: i64 =
99 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = $1")
100 .bind(user_id)
101 .fetch_one(&h.db)
102 .await
103 .unwrap();
104 assert_eq!(user_exists, 0, "Sandbox user should be deleted");
105
106 let projects_left: i64 =
107 sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE user_id = $1")
108 .bind(user_id)
109 .fetch_one(&h.db)
110 .await
111 .unwrap();
112 assert_eq!(projects_left, 0, "Projects should be cascade-deleted");
113 }
114
115 // =============================================================================
116 // Creator tier upgrade: SmallFiles → BigFiles → verify limits change
117 // =============================================================================
118
119 #[tokio::test]
120 async fn creator_tier_upgrade_changes_limits() {
121 let mut h = TestHarness::with_storage().await;
122 let user_id = h.create_creator("tierup").await;
123
124 // Start with small_files tier
125 h.grant_tier(user_id, "small_files").await;
126
127 // Verify via DB
128 let tier: String =
129 sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
130 .bind(user_id)
131 .fetch_one(&h.db)
132 .await
133 .unwrap();
134 assert_eq!(tier, "small_files");
135
136 // Verify subscription row exists with correct tier
137 let sub_tier: String =
138 sqlx::query_scalar("SELECT tier FROM creator_subscriptions WHERE user_id = $1")
139 .bind(user_id)
140 .fetch_one(&h.db)
141 .await
142 .unwrap();
143 assert_eq!(sub_tier, "small_files");
144
145 // Upgrade to big_files
146 h.grant_tier(user_id, "big_files").await;
147
148 let tier: String =
149 sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
150 .bind(user_id)
151 .fetch_one(&h.db)
152 .await
153 .unwrap();
154 assert_eq!(tier, "big_files");
155
156 // Verify subscription row was UPDATED (not duplicated — ON CONFLICT DO UPDATE)
157 let sub_count: i64 = sqlx::query_scalar(
158 "SELECT COUNT(*) FROM creator_subscriptions WHERE user_id = $1",
159 )
160 .bind(user_id)
161 .fetch_one(&h.db)
162 .await
163 .unwrap();
164 assert_eq!(sub_count, 1, "Upgrade should update, not duplicate subscription");
165
166 let sub_tier: String =
167 sqlx::query_scalar("SELECT tier FROM creator_subscriptions WHERE user_id = $1")
168 .bind(user_id)
169 .fetch_one(&h.db)
170 .await
171 .unwrap();
172 assert_eq!(sub_tier, "big_files");
173
174 // Upgrade to everything
175 h.grant_tier(user_id, "everything").await;
176
177 let tier: String =
178 sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
179 .bind(user_id)
180 .fetch_one(&h.db)
181 .await
182 .unwrap();
183 assert_eq!(tier, "everything");
184
185 // Re-login and verify dashboard loads with new tier
186 h.client.post_form("/logout", "").await;
187 h.login("tierup", "password123").await;
188 let resp = h.client.get("/dashboard").await;
189 assert!(
190 resp.status.is_success(),
191 "Dashboard should load after tier upgrade, got {}",
192 resp.status
193 );
194 }
195
196 // =============================================================================
197 // Concurrent promo code: 2 buyers apply same max_uses=1 code → only 1 succeeds
198 // =============================================================================
199
200 #[tokio::test]
201 async fn concurrent_promo_code_max_uses_one() {
202 let mut h = TestHarness::new().await;
203
204 // Create a creator with an item
205 let seller_id = h.create_creator("promosel").await;
206 let resp = h
207 .client
208 .post_form("/api/projects", "slug=promo-race&title=Promo+Race")
209 .await;
210 assert!(resp.status.is_success());
211 let project: Value = resp.json();
212 let project_id = project["id"].as_str().unwrap().to_string();
213
214 let resp = h
215 .client
216 .post_form(
217 &format!("/api/projects/{}/items", project_id),
218 "title=Race+Item&item_type=digital&price_cents=500",
219 )
220 .await;
221 assert!(resp.status.is_success());
222
223 // Create a promo code with max_uses = 1
224 sqlx::query(
225 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses)
226 VALUES ($1, 'ONLYONE', 'discount', 'percentage', 100, 0, 1)",
227 )
228 .bind(seller_id)
229 .execute(&h.db)
230 .await
231 .unwrap();
232
233 // Get the promo code id
234 let promo_id: uuid::Uuid = sqlx::query_scalar::<_, uuid::Uuid>(
235 "SELECT id FROM promo_codes WHERE code = 'ONLYONE'",
236 )
237 .fetch_one(&h.db)
238 .await
239 .unwrap();
240
241 // Simulate concurrent increment attempts using try_increment_use_count logic
242 // (The actual SQL: UPDATE ... WHERE use_count < max_uses)
243 let result1 = sqlx::query(
244 "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
245 )
246 .bind(promo_id)
247 .execute(&h.db)
248 .await
249 .unwrap();
250
251 let result2 = sqlx::query(
252 "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
253 )
254 .bind(promo_id)
255 .execute(&h.db)
256 .await
257 .unwrap();
258
259 // First should succeed, second should be a no-op (WHERE clause fails)
260 let total_affected = result1.rows_affected() + result2.rows_affected();
261 assert_eq!(
262 total_affected, 1,
263 "Only 1 of 2 concurrent increments should succeed for max_uses=1, got {}",
264 total_affected
265 );
266
267 // Verify use_count is exactly 1
268 let use_count: i32 = sqlx::query_scalar(
269 "SELECT use_count FROM promo_codes WHERE id = $1",
270 )
271 .bind(promo_id)
272 .fetch_one(&h.db)
273 .await
274 .unwrap();
275 assert_eq!(use_count, 1, "use_count should be exactly 1");
276 }
277
278 // =============================================================================
279 // Concurrent sandbox creation: same IP → per-IP cap holds
280 // =============================================================================
281
282 #[tokio::test]
283 #[cfg_attr(not(feature = "fast-tests"), ignore)]
284 async fn concurrent_sandbox_per_ip_cap_holds() {
285 let mut h = TestHarness::new().await;
286
287 // Create sandboxes up to the cap without logging out.
288 // The cap counts concurrent sessions per IP — logout deletes session rows,
289 // which would break the count.
290 let cap = makenotwork::constants::SANDBOX_MAX_PER_IP;
291 for i in 0..cap {
292 let resp = h.client.get("/sandbox").await;
293 assert!(resp.status.is_success());
294 let resp = h.client.post_form("/sandbox", "").await;
295 assert!(
296 resp.status.is_redirection(),
297 "Sandbox {} should succeed, got {}",
298 i + 1,
299 resp.status
300 );
301 }
302
303 // Count how many sandbox users exist
304 let count: i64 = sqlx::query_scalar(
305 "SELECT COUNT(*) FROM users WHERE is_sandbox = TRUE",
306 )
307 .fetch_one(&h.db)
308 .await
309 .unwrap();
310 assert_eq!(count, cap, "Should have exactly {} sandbox users", cap);
311
312 // Try to create one more — should fail (cap reached)
313 h.client.get("/sandbox").await;
314 let resp = h.client.post_form("/sandbox", "").await;
315 assert_eq!(
316 resp.status.as_u16(),
317 400,
318 "Sandbox beyond cap should return 400, got {}",
319 resp.status
320 );
321
322 // Count should still be at cap
323 let count_after: i64 = sqlx::query_scalar(
324 "SELECT COUNT(*) FROM users WHERE is_sandbox = TRUE",
325 )
326 .fetch_one(&h.db)
327 .await
328 .unwrap();
329 assert_eq!(
330 count_after, cap,
331 "No new sandbox should be created beyond cap"
332 );
333 }
334
335 // =============================================================================
336 // Concurrent purchase: 2 buyers checkout same item → sales_count correct
337 // =============================================================================
338
339 #[tokio::test]
340 async fn concurrent_purchases_sales_count_correct() {
341 let mut h = TestHarness::new().await;
342
343 // Create a creator with a free item (so we can claim without Stripe)
344 let _creator_id = h.create_creator("salescount").await;
345 let resp = h
346 .client
347 .post_form("/api/projects", "slug=sales-race&title=Sales+Race")
348 .await;
349 assert!(resp.status.is_success());
350 let project: Value = resp.json();
351 let project_id = project["id"].as_str().unwrap().to_string();
352
353 let resp = h
354 .client
355 .post_form(
356 &format!("/api/projects/{}/items", project_id),
357 "title=Free+Race&item_type=digital&price_cents=0",
358 )
359 .await;
360 assert!(resp.status.is_success());
361 let item: Value = resp.json();
362 let item_id = item["id"].as_str().unwrap().to_string();
363
364 // Publish
365 h.publish_project_and_item(&project_id, &item_id).await;
366 h.client.post_form("/logout", "").await;
367
368 // Create 5 buyers and have them each claim the free item
369 for i in 0..5 {
370 let username = format!("racer{}", i);
371 h.signup(&username, &format!("{}@test.com", username), "password123")
372 .await;
373 let resp = h
374 .client
375 .post_form(&format!("/api/library/add/{}", item_id), "")
376 .await;
377 assert!(
378 resp.status.is_success(),
379 "Free claim {} should succeed, got: {} {}",
380 i,
381 resp.status,
382 resp.text
383 );
384 h.client.post_form("/logout", "").await;
385 }
386
387 // Verify sales_count is exactly 5
388 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
389 .bind(&item_id)
390 .fetch_one(&h.db)
391 .await
392 .unwrap();
393 assert_eq!(sales, 5, "sales_count should be exactly 5 after 5 purchases");
394
395 // Verify 5 completed transactions exist
396 let tx_count: i64 = sqlx::query_scalar(
397 "SELECT COUNT(*) FROM transactions WHERE item_id = $1::uuid AND status = 'completed'",
398 )
399 .bind(&item_id)
400 .fetch_one(&h.db)
401 .await
402 .unwrap();
403 assert_eq!(tx_count, 5, "Should have 5 completed transactions");
404 }
405
406 // =============================================================================
407 // Promo code lifecycle: create → use → exhaust → delete
408 // =============================================================================
409
410 #[tokio::test]
411 async fn promo_code_full_lifecycle() {
412 let mut h = TestHarness::new().await;
413
414 let _creator_id = h.create_creator("promolife").await;
415
416 // Create a project (needed for promo code scoping)
417 let resp = h
418 .client
419 .post_form("/api/projects", "slug=promo-life&title=Promo+Life")
420 .await;
421 assert!(resp.status.is_success());
422 let project: Value = resp.json();
423 let project_id = project["id"].as_str().unwrap().to_string();
424
425 // Step 1: Create promo code via API
426 let resp = h
427 .client
428 .post_form(
429 "/api/promo-codes",
430 &format!(
431 "code=LIFECYCLE&code_purpose=discount&discount_type=percentage&discount_value=50&max_uses=2&project_id={}",
432 project_id
433 ),
434 )
435 .await;
436 assert!(
437 resp.status.is_success() || resp.status.is_redirection(),
438 "Create promo code failed: {} {}",
439 resp.status,
440 resp.text
441 );
442
443 // Step 2: Verify it exists in the DB
444 let code_id: String = sqlx::query_scalar::<_, uuid::Uuid>(
445 "SELECT id FROM promo_codes WHERE code = 'LIFECYCLE'",
446 )
447 .fetch_one(&h.db)
448 .await
449 .expect("Promo code should exist")
450 .to_string();
451
452 let (use_count, max_uses): (i32, Option<i32>) = sqlx::query_as(
453 "SELECT use_count, max_uses FROM promo_codes WHERE code = 'LIFECYCLE'",
454 )
455 .fetch_one(&h.db)
456 .await
457 .unwrap();
458 assert_eq!(use_count, 0);
459 assert_eq!(max_uses, Some(2));
460
461 // Step 3: Simulate usage (increment use_count twice)
462 sqlx::query("UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1::uuid")
463 .bind(&code_id)
464 .execute(&h.db)
465 .await
466 .unwrap();
467 sqlx::query("UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1::uuid")
468 .bind(&code_id)
469 .execute(&h.db)
470 .await
471 .unwrap();
472
473 // Step 4: Verify exhausted (use_count == max_uses)
474 let (use_count, max_uses): (i32, Option<i32>) = sqlx::query_as(
475 "SELECT use_count, max_uses FROM promo_codes WHERE id = $1::uuid",
476 )
477 .bind(&code_id)
478 .fetch_one(&h.db)
479 .await
480 .unwrap();
481 assert_eq!(use_count, 2);
482 assert_eq!(max_uses, Some(2));
483
484 // Step 5: try_increment should fail (exhausted)
485 let result = sqlx::query(
486 "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1::uuid AND (max_uses IS NULL OR use_count < max_uses)",
487 )
488 .bind(&code_id)
489 .execute(&h.db)
490 .await
491 .unwrap();
492 assert_eq!(
493 result.rows_affected(),
494 0,
495 "Exhausted promo code should not increment"
496 );
497
498 // Step 6: Delete promo code
499 let resp = h
500 .client
501 .delete(&format!("/api/promo-codes/{}", code_id))
502 .await;
503 assert!(
504 resp.status.is_success(),
505 "Delete promo code failed: {} {}",
506 resp.status,
507 resp.text
508 );
509
510 // Step 7: Verify gone
511 let count: i64 = sqlx::query_scalar(
512 "SELECT COUNT(*) FROM promo_codes WHERE id = $1::uuid",
513 )
514 .bind(&code_id)
515 .fetch_one(&h.db)
516 .await
517 .unwrap();
518 assert_eq!(count, 0, "Promo code should be deleted");
519 }
520
521 // =============================================================================
522 // Account deletion with 30-day export window
523 // =============================================================================
524
525 #[tokio::test]
526 async fn account_deletion_export_window() {
527 let mut h = TestHarness::new().await;
528
529 // Create user with content
530 let user_id = h.create_creator("exporter").await;
531 let resp = h
532 .client
533 .post_form("/api/projects", "slug=export-test&title=Export+Test")
534 .await;
535 assert!(resp.status.is_success());
536 let project: Value = resp.json();
537 let project_id = project["id"].as_str().unwrap().to_string();
538
539 let resp = h
540 .client
541 .post_form(
542 &format!("/api/projects/{}/items", project_id),
543 "title=Exportable+Item&item_type=digital&price_cents=0",
544 )
545 .await;
546 assert!(resp.status.is_success());
547
548 // Request deletion
549 let resp = h
550 .client
551 .post_form("/api/account/request-deletion", "username=exporter")
552 .await;
553 assert!(
554 resp.status.is_success(),
555 "Request deletion failed: {} {}",
556 resp.status,
557 resp.text
558 );
559
560 // Verify user still exists (not deleted yet — waiting for confirmation link)
561 let user_exists: i64 =
562 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = $1")
563 .bind(user_id)
564 .fetch_one(&h.db)
565 .await
566 .unwrap();
567 assert_eq!(user_exists, 1, "User should still exist before confirmation");
568
569 // Verify content still accessible
570 let project_exists: i64 = sqlx::query_scalar(
571 "SELECT COUNT(*) FROM projects WHERE user_id = $1",
572 )
573 .bind(user_id)
574 .fetch_one(&h.db)
575 .await
576 .unwrap();
577 assert_eq!(
578 project_exists, 1,
579 "Projects should still exist during export window"
580 );
581 }
582
583 // =============================================================================
584 // Subscription lifecycle: subscribe → active → past_due → recover → cancel
585 // =============================================================================
586
587 #[tokio::test]
588 async fn subscription_lifecycle_subscribe_cancel_access_revoked() {
589 let mut h = TestHarness::new().await;
590
591 // Step 1: Creator sets up a project with a subscription tier
592 let _creator_id = h.create_creator("subhost").await;
593 let resp = h
594 .client
595 .post_form("/api/projects", "slug=sub-life&title=Sub+Life")
596 .await;
597 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
598 let project: Value = resp.json();
599 let project_id = project["id"].as_str().unwrap().to_string();
600 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
601
602 // Publish project
603 h.client
604 .put_json(
605 &format!("/api/projects/{}", project_id),
606 r#"{"is_public": true}"#,
607 )
608 .await;
609
610 // Insert subscription tier via SQL (bypasses Stripe product creation)
611 let tier_id: uuid::Uuid = sqlx::query_scalar(
612 "INSERT INTO subscription_tiers (project_id, name, price_cents, stripe_product_id, stripe_price_id)
613 VALUES ($1, 'Premium', 500, 'prod_test', 'price_test')
614 RETURNING id",
615 )
616 .bind(project_uuid)
617 .fetch_one(&h.db)
618 .await
619 .unwrap();
620
621 h.client.post_form("/logout", "").await;
622
623 // Step 2: Subscriber signs up
624 let subscriber_id = h.signup("subfan", "subfan@test.com", "password123").await;
625 h.client.post_form("/logout", "").await;
626
627 // Step 3: Simulate subscription creation (what handle_subscription_checkout_completed does)
628 let stripe_sub_id = "sub_lifecycle_test_001";
629 let stripe_customer_id = "cus_lifecycle_test_001";
630 let now = chrono::Utc::now();
631 let period_end = now + chrono::Duration::days(30);
632
633 sqlx::query(
634 "INSERT INTO subscriptions (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end)
635 VALUES ($1, $2, $3, $4, $5, 'active', $6, $7)",
636 )
637 .bind(subscriber_id)
638 .bind(tier_id)
639 .bind(project_uuid)
640 .bind(stripe_sub_id)
641 .bind(stripe_customer_id)
642 .bind(now)
643 .bind(period_end)
644 .execute(&h.db)
645 .await
646 .unwrap();
647
648 // Step 4: Verify subscriber has access (mirrors db::subscriptions::has_access)
649 let has_access: bool = sqlx::query_scalar(
650 "SELECT COUNT(*) > 0 FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL",
651 )
652 .bind(subscriber_id)
653 .bind(project_uuid)
654 .fetch_one(&h.db)
655 .await
656 .unwrap();
657 assert!(has_access, "Subscriber should have access after subscribing");
658
659 // Verify subscriber count = 1
660 let sub_count: i64 = sqlx::query_scalar(
661 "SELECT COUNT(*) FROM subscriptions WHERE project_id = $1 AND status = 'active'",
662 )
663 .bind(project_uuid)
664 .fetch_one(&h.db)
665 .await
666 .unwrap();
667 assert_eq!(sub_count, 1, "Project should have 1 active subscriber");
668
669 // Step 5: Simulate subscription status → past_due (missed payment)
670 sqlx::query(
671 "UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = $1",
672 )
673 .bind(stripe_sub_id)
674 .execute(&h.db)
675 .await
676 .unwrap();
677
678 // past_due still counts as non-active for access check
679 let has_access_past_due: bool = sqlx::query_scalar(
680 "SELECT COUNT(*) > 0 FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL",
681 )
682 .bind(subscriber_id)
683 .bind(project_uuid)
684 .fetch_one(&h.db)
685 .await
686 .unwrap();
687 assert!(
688 !has_access_past_due,
689 "Subscriber should NOT have access when past_due"
690 );
691
692 // Step 6: Restore to active (payment recovered)
693 sqlx::query(
694 "UPDATE subscriptions SET status = 'active' WHERE stripe_subscription_id = $1",
695 )
696 .bind(stripe_sub_id)
697 .execute(&h.db)
698 .await
699 .unwrap();
700
701 let has_access_restored: bool = sqlx::query_scalar(
702 "SELECT COUNT(*) > 0 FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL",
703 )
704 .bind(subscriber_id)
705 .bind(project_uuid)
706 .fetch_one(&h.db)
707 .await
708 .unwrap();
709 assert!(has_access_restored, "Subscriber should regain access after payment recovery");
710
711 // Step 7: Cancel subscription (mirrors cancel_subscription DB function)
712 sqlx::query(
713 "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = $1",
714 )
715 .bind(stripe_sub_id)
716 .execute(&h.db)
717 .await
718 .unwrap();
719
720 // Step 8: Verify subscription is canceled
721 let status: String = sqlx::query_scalar(
722 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
723 )
724 .bind(stripe_sub_id)
725 .fetch_one(&h.db)
726 .await
727 .unwrap();
728 assert_eq!(status, "canceled");
729
730 let canceled_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
731 "SELECT canceled_at FROM subscriptions WHERE stripe_subscription_id = $1",
732 )
733 .bind(stripe_sub_id)
734 .fetch_one(&h.db)
735 .await
736 .unwrap();
737 assert!(canceled_at.is_some(), "canceled_at should be set");
738
739 // Step 9: Verify subscriber NO LONGER has access
740 let has_access_after: bool = sqlx::query_scalar(
741 "SELECT COUNT(*) > 0 FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL",
742 )
743 .bind(subscriber_id)
744 .bind(project_uuid)
745 .fetch_one(&h.db)
746 .await
747 .unwrap();
748 assert!(
749 !has_access_after,
750 "Subscriber should NOT have access after cancellation"
751 );
752
753 // Step 10: Verify tier delete soft-deletes when subscriptions exist
754 // (instead of hard-deleting, since this tier has subscription references)
755 h.login("subhost", "password123").await;
756 let resp = h
757 .client
758 .delete(&format!("/api/tiers/{}", tier_id))
759 .await;
760 assert_eq!(resp.status.as_u16(), 204, "Delete tier should return 204");
761
762 // Tier should still exist in DB (soft-deleted: is_active=false)
763 let (tier_exists, tier_active): (bool, bool) = sqlx::query_as(
764 "SELECT EXISTS(SELECT 1 FROM subscription_tiers WHERE id = $1), COALESCE((SELECT is_active FROM subscription_tiers WHERE id = $1), false)",
765 )
766 .bind(tier_id)
767 .fetch_one(&h.db)
768 .await
769 .unwrap();
770 assert!(tier_exists, "Tier should still exist (soft-deleted)");
771 assert!(!tier_active, "Tier should be deactivated (is_active=false)");
772 }
773
774 // =============================================================================
775 // Concurrent file upload: 2 increments → storage_used_bytes correct
776 // =============================================================================
777
778 #[tokio::test]
779 async fn concurrent_storage_increment_correct() {
780 let mut h = TestHarness::new().await;
781 let user_id = h.create_creator("storagerace").await;
782 h.grant_tier(user_id, "small_files").await;
783
784 // Start with 0 bytes
785 let initial: i64 = sqlx::query_scalar(
786 "SELECT storage_used_bytes FROM users WHERE id = $1",
787 )
788 .bind(user_id)
789 .fetch_one(&h.db)
790 .await
791 .unwrap();
792 assert_eq!(initial, 0);
793
794 let cap = 50_000_000_000i64; // 50 GB (well above what we'll use)
795
796 // Simulate two concurrent uploads: 10 MB and 20 MB
797 let upload_a = 10 * 1024 * 1024i64;
798 let upload_b = 20 * 1024 * 1024i64;
799
800 // Run both increments concurrently via tokio::join
801 let pool = h.db.clone();
802 let pool2 = h.db.clone();
803 let (result_a, result_b) = tokio::join!(
804 sqlx::query(
805 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
806 )
807 .bind(user_id)
808 .bind(upload_a)
809 .bind(cap)
810 .execute(&pool),
811 sqlx::query(
812 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
813 )
814 .bind(user_id)
815 .bind(upload_b)
816 .bind(cap)
817 .execute(&pool2),
818 );
819
820 assert!(result_a.is_ok(), "Upload A should succeed");
821 assert!(result_b.is_ok(), "Upload B should succeed");
822 assert_eq!(result_a.unwrap().rows_affected(), 1);
823 assert_eq!(result_b.unwrap().rows_affected(), 1);
824
825 // Verify final storage is exactly the sum
826 let final_bytes: i64 = sqlx::query_scalar(
827 "SELECT storage_used_bytes FROM users WHERE id = $1",
828 )
829 .bind(user_id)
830 .fetch_one(&h.db)
831 .await
832 .unwrap();
833 assert_eq!(
834 final_bytes,
835 upload_a + upload_b,
836 "Concurrent increments should sum correctly: expected {}, got {}",
837 upload_a + upload_b,
838 final_bytes
839 );
840 }
841
842 #[tokio::test]
843 async fn concurrent_storage_increment_respects_cap() {
844 let mut h = TestHarness::new().await;
845 let user_id = h.create_creator("storagecap").await;
846 h.grant_tier(user_id, "small_files").await;
847
848 let cap = 1_000_000i64; // 1 MB cap
849
850 // Two uploads that individually fit but together exceed the cap
851 let upload_a = 700_000i64;
852 let upload_b = 700_000i64;
853
854 let pool = h.db.clone();
855 let pool2 = h.db.clone();
856 let (result_a, result_b) = tokio::join!(
857 sqlx::query(
858 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
859 )
860 .bind(user_id)
861 .bind(upload_a)
862 .bind(cap)
863 .execute(&pool),
864 sqlx::query(
865 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
866 )
867 .bind(user_id)
868 .bind(upload_b)
869 .bind(cap)
870 .execute(&pool2),
871 );
872
873 // Both queries succeed at the SQL level, but only one should affect a row
874 let affected_a = result_a.unwrap().rows_affected();
875 let affected_b = result_b.unwrap().rows_affected();
876
877 // Exactly one should succeed (the other's WHERE clause fails after the first commits)
878 assert_eq!(
879 affected_a + affected_b, 1,
880 "Only 1 of 2 concurrent uploads should fit under the cap, got {} + {}",
881 affected_a, affected_b
882 );
883
884 // Final storage should be exactly one upload's worth
885 let final_bytes: i64 = sqlx::query_scalar(
886 "SELECT storage_used_bytes FROM users WHERE id = $1",
887 )
888 .bind(user_id)
889 .fetch_one(&h.db)
890 .await
891 .unwrap();
892 assert_eq!(final_bytes, 700_000, "Should have exactly one upload's worth");
893 }
894