Skip to main content

max / makenotwork

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