Skip to main content

max / makenotwork

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