Skip to main content

max / makenotwork

21.2 KB · 663 lines History Blame Raw
1 //! Subscription tier workflow: create project -> validation errors ->
2 //! insert tier via SQL -> list -> update -> delete -> verify gone
3 //!
4 //! Note: Creating tiers through the API requires Stripe (not available in tests),
5 //! so we insert tiers directly via SQL and test list/update/delete through the API.
6 //! We also verify that the API returns proper validation errors on create attempts.
7
8 use crate::harness::TestHarness;
9 use serde_json::Value;
10
11 #[tokio::test]
12 async fn subscription_tier_lifecycle() {
13 let mut h = TestHarness::new().await;
14
15 // Setup: creator with project
16 let user_id = h
17 .signup("submaker", "submaker@example.com", "password123")
18 .await;
19 h.grant_creator(user_id).await;
20 h.client.post_form("/logout", "").await;
21 h.login("submaker", "password123").await;
22
23 let resp = h
24 .client
25 .post_form("/api/projects", "slug=sub-project&title=Sub+Project")
26 .await;
27 assert!(
28 resp.status.is_success(),
29 "Create project failed: {} {}",
30 resp.status,
31 resp.text
32 );
33 let project: Value = resp.json();
34 let project_id = project["id"].as_str().expect("project should have id");
35 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
36
37 // ── Validation: empty name returns 422 ──
38 let resp = h
39 .client
40 .post_json(
41 &format!("/api/projects/{project_id}/tiers"),
42 r#"{"name": "", "description": null, "price_cents": 500}"#,
43 )
44 .await;
45 assert_eq!(
46 resp.status, 422,
47 "Empty tier name should return 422, got {} {}",
48 resp.status, resp.text
49 );
50
51 // ── Validation: price below minimum returns 422 ──
52 let resp = h
53 .client
54 .post_json(
55 &format!("/api/projects/{project_id}/tiers"),
56 r#"{"name": "Basic", "description": null, "price_cents": 50}"#,
57 )
58 .await;
59 assert_eq!(
60 resp.status, 422,
61 "Price below minimum should return 422, got {} {}",
62 resp.status, resp.text
63 );
64
65 // ── Validation: valid input but no Stripe returns 400 ──
66 // NOTE: The create_tier handler inserts the tier into the DB before
67 // attempting Stripe product creation. When Stripe is not configured the
68 // Stripe step fails with 400, but the tier row already exists. We clean
69 // it up here so the rest of the test starts from a known state.
70 let resp = h
71 .client
72 .post_json(
73 &format!("/api/projects/{project_id}/tiers"),
74 r#"{"name": "Premium", "description": "Full access", "price_cents": 500}"#,
75 )
76 .await;
77 assert_eq!(
78 resp.status, 400,
79 "Create tier without Stripe should return 400, got {} {}",
80 resp.status, resp.text
81 );
82 assert!(
83 resp.text.contains("Stripe"),
84 "Error message should mention Stripe"
85 );
86 // Clean up the orphaned tier row left by the failed Stripe step
87 sqlx::query("DELETE FROM subscription_tiers WHERE project_id = $1")
88 .bind(project_uuid)
89 .execute(&h.db)
90 .await
91 .expect("clean up orphaned tier");
92
93 // ── Insert tier directly via SQL (bypassing Stripe) ──
94 let tier_id = sqlx::query_scalar::<_, uuid::Uuid>(
95 "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \
96 VALUES ($1, $2, $3, $4) RETURNING id",
97 )
98 .bind(project_uuid)
99 .bind("Basic Tier")
100 .bind(Some("Access to basic content"))
101 .bind(500)
102 .fetch_one(&h.db)
103 .await
104 .expect("Failed to insert tier via SQL");
105
106 // ── List tiers: should contain the inserted tier ──
107 let resp = h
108 .client
109 .get(&format!("/api/projects/{project_id}/tiers"))
110 .await;
111 assert!(
112 resp.status.is_success(),
113 "List tiers failed: {} {}",
114 resp.status,
115 resp.text
116 );
117 let body: Value = resp.json();
118 let tiers = body["data"]
119 .as_array()
120 .expect("response should have data array");
121 assert_eq!(tiers.len(), 1, "Should have exactly 1 tier");
122 assert_eq!(tiers[0]["name"], "Basic Tier");
123 assert_eq!(tiers[0]["price_cents"], 500);
124 assert_eq!(tiers[0]["is_active"], true);
125 assert_eq!(
126 tiers[0]["description"], "Access to basic content",
127 "Tier description should match"
128 );
129
130 // ── Update tier: change name and description ──
131 let resp = h
132 .client
133 .put_json(
134 &format!("/api/tiers/{tier_id}"),
135 r#"{"name": "Premium Tier", "description": "Full access to everything", "is_active": true}"#,
136 )
137 .await;
138 assert!(
139 resp.status.is_success(),
140 "Update tier failed: {} {}",
141 resp.status,
142 resp.text
143 );
144 let updated: Value = resp.json();
145 assert_eq!(updated["name"], "Premium Tier");
146 assert_eq!(updated["description"], "Full access to everything");
147 assert_eq!(updated["is_active"], true);
148
149 // ── Verify update persisted via list ──
150 let resp = h
151 .client
152 .get(&format!("/api/projects/{project_id}/tiers"))
153 .await;
154 let body: Value = resp.json();
155 let tiers = body["data"]
156 .as_array()
157 .expect("response should have data array");
158 assert_eq!(
159 tiers[0]["name"], "Premium Tier",
160 "Updated name should persist"
161 );
162
163 // ── Update validation: empty name returns 422 ──
164 let resp = h
165 .client
166 .put_json(
167 &format!("/api/tiers/{tier_id}"),
168 r#"{"name": "", "description": null, "is_active": true}"#,
169 )
170 .await;
171 assert_eq!(
172 resp.status, 422,
173 "Update with empty name should return 422, got {} {}",
174 resp.status, resp.text
175 );
176
177 // ── Delete tier (no subscriptions -> hard delete) ──
178 let resp = h.client.delete(&format!("/api/tiers/{tier_id}")).await;
179 assert_eq!(
180 resp.status, 204,
181 "Delete tier should return 204 No Content, got {} {}",
182 resp.status, resp.text
183 );
184
185 // ── Verify tier is gone from list ──
186 let resp = h
187 .client
188 .get(&format!("/api/projects/{project_id}/tiers"))
189 .await;
190 let body: Value = resp.json();
191 let tiers = body["data"]
192 .as_array()
193 .expect("response should have data array");
194 assert_eq!(tiers.len(), 0, "Tier list should be empty after deletion");
195
196 // ── Verify tier is gone from database (hard delete) ──
197 let count =
198 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM subscription_tiers WHERE id = $1")
199 .bind(tier_id)
200 .fetch_one(&h.db)
201 .await
202 .unwrap();
203 assert_eq!(count, 0, "Tier should be hard-deleted from database");
204 }
205
206 #[tokio::test]
207 async fn create_subscription_tier() {
208 let mut h = TestHarness::new().await;
209
210 // Setup: creator with project
211 let _user_id = h.create_creator("tiercreator").await;
212
213 let resp = h
214 .client
215 .post_form("/api/projects", "slug=tier-create&title=Tier+Create")
216 .await;
217 assert!(
218 resp.status.is_success(),
219 "Create project failed: {}",
220 resp.text
221 );
222 let project: Value = resp.json();
223 let project_id = project["id"].as_str().unwrap();
224 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
225
226 // ── Attempt to create a tier via API (no Stripe configured -> 400) ──
227 let resp = h
228 .client
229 .post_json(
230 &format!("/api/projects/{project_id}/tiers"),
231 r#"{"name": "Gold Tier", "description": "Premium access", "price_cents": 1000}"#,
232 )
233 .await;
234 assert_eq!(
235 resp.status, 400,
236 "Create tier without Stripe should return 400, got {} {}",
237 resp.status, resp.text
238 );
239
240 // The handler inserts the row before calling Stripe, so clean up the orphan
241 sqlx::query("DELETE FROM subscription_tiers WHERE project_id = $1")
242 .bind(project_uuid)
243 .execute(&h.db)
244 .await
245 .unwrap();
246
247 // ── Insert tier via SQL (simulating successful Stripe flow) ──
248 let tier_id = sqlx::query_scalar::<_, uuid::Uuid>(
249 "INSERT INTO subscription_tiers (project_id, name, description, price_cents, stripe_product_id, stripe_price_id) \
250 VALUES ($1, 'Gold Tier', 'Premium access', 1000, 'prod_test_123', 'price_test_123') RETURNING id",
251 )
252 .bind(project_uuid)
253 .fetch_one(&h.db)
254 .await
255 .unwrap();
256
257 // ── Verify it appears in the list (project settings) ──
258 let resp = h
259 .client
260 .get(&format!("/api/projects/{project_id}/tiers"))
261 .await;
262 assert!(resp.status.is_success(), "List tiers failed: {}", resp.text);
263 let body: Value = resp.json();
264 let tiers = body["data"]
265 .as_array()
266 .expect("response should have data array");
267 assert_eq!(tiers.len(), 1, "Should have exactly 1 tier");
268 assert_eq!(tiers[0]["name"], "Gold Tier");
269 assert_eq!(tiers[0]["description"], "Premium access");
270 assert_eq!(tiers[0]["price_cents"], 1000);
271 assert_eq!(tiers[0]["is_active"], true);
272
273 // ── Verify Stripe IDs are set in the database ──
274 let (prod_id, price_id): (Option<String>, Option<String>) = sqlx::query_as(
275 "SELECT stripe_product_id, stripe_price_id FROM subscription_tiers WHERE id = $1",
276 )
277 .bind(tier_id)
278 .fetch_one(&h.db)
279 .await
280 .unwrap();
281 assert_eq!(prod_id.as_deref(), Some("prod_test_123"));
282 assert_eq!(price_id.as_deref(), Some("price_test_123"));
283 }
284
285 #[tokio::test]
286 async fn list_subscription_tiers() {
287 let mut h = TestHarness::new().await;
288
289 let _user_id = h.create_creator("tierlist").await;
290
291 let resp = h
292 .client
293 .post_form("/api/projects", "slug=tier-list&title=Tier+List")
294 .await;
295 assert!(resp.status.is_success());
296 let project: Value = resp.json();
297 let project_id = project["id"].as_str().unwrap();
298 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
299
300 // ── Insert multiple tiers with explicit sort_order ──
301 for (name, price, order) in [("Bronze", 300, 1), ("Silver", 600, 2), ("Gold", 1200, 3)] {
302 sqlx::query(
303 "INSERT INTO subscription_tiers (project_id, name, price_cents, sort_order) \
304 VALUES ($1, $2, $3, $4)",
305 )
306 .bind(project_uuid)
307 .bind(name)
308 .bind(price)
309 .bind(order)
310 .execute(&h.db)
311 .await
312 .unwrap();
313 }
314
315 // ── List tiers: should return all 3 in order ──
316 let resp = h
317 .client
318 .get(&format!("/api/projects/{project_id}/tiers"))
319 .await;
320 assert!(resp.status.is_success(), "List tiers failed: {}", resp.text);
321 let body: Value = resp.json();
322 let tiers = body["data"]
323 .as_array()
324 .expect("response should have data array");
325 assert_eq!(tiers.len(), 3, "Should have 3 tiers");
326 assert_eq!(tiers[0]["name"], "Bronze");
327 assert_eq!(tiers[0]["price_cents"], 300);
328 assert_eq!(tiers[1]["name"], "Silver");
329 assert_eq!(tiers[1]["price_cents"], 600);
330 assert_eq!(tiers[2]["name"], "Gold");
331 assert_eq!(tiers[2]["price_cents"], 1200);
332 }
333
334 #[tokio::test]
335 async fn update_subscription_tier() {
336 let mut h = TestHarness::new().await;
337
338 let _user_id = h.create_creator("tierupd").await;
339
340 let resp = h
341 .client
342 .post_form("/api/projects", "slug=tier-upd&title=Tier+Update")
343 .await;
344 assert!(resp.status.is_success());
345 let project: Value = resp.json();
346 let project_id = project["id"].as_str().unwrap();
347 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
348
349 // ── Insert a tier via SQL ──
350 let tier_id = sqlx::query_scalar::<_, uuid::Uuid>(
351 "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \
352 VALUES ($1, 'Starter', 'Basic access', 500) RETURNING id",
353 )
354 .bind(project_uuid)
355 .fetch_one(&h.db)
356 .await
357 .unwrap();
358
359 // ── Update name and description via API ──
360 let resp = h
361 .client
362 .put_json(
363 &format!("/api/tiers/{tier_id}"),
364 r#"{"name": "Pro", "description": "Full access to everything", "is_active": true}"#,
365 )
366 .await;
367 assert!(
368 resp.status.is_success(),
369 "Update tier failed: {} {}",
370 resp.status,
371 resp.text
372 );
373 let updated: Value = resp.json();
374 assert_eq!(updated["name"], "Pro");
375 assert_eq!(updated["description"], "Full access to everything");
376 assert_eq!(updated["is_active"], true);
377
378 // ── Verify changes persisted via list endpoint ──
379 let resp = h
380 .client
381 .get(&format!("/api/projects/{project_id}/tiers"))
382 .await;
383 let body: Value = resp.json();
384 let tiers = body["data"].as_array().unwrap();
385 assert_eq!(tiers.len(), 1);
386 assert_eq!(tiers[0]["name"], "Pro", "Updated name should persist");
387 assert_eq!(
388 tiers[0]["description"], "Full access to everything",
389 "Updated description should persist"
390 );
391
392 // ── Update to deactivate ──
393 let resp = h
394 .client
395 .put_json(
396 &format!("/api/tiers/{tier_id}"),
397 r#"{"name": "Pro", "description": "Full access to everything", "is_active": false}"#,
398 )
399 .await;
400 assert!(resp.status.is_success());
401 let updated: Value = resp.json();
402 assert_eq!(updated["is_active"], false, "Tier should be deactivated");
403 }
404
405 #[tokio::test]
406 async fn delete_subscription_tier() {
407 let mut h = TestHarness::new().await;
408
409 let _user_id = h.create_creator("tierdel").await;
410
411 let resp = h
412 .client
413 .post_form("/api/projects", "slug=tier-del&title=Tier+Delete")
414 .await;
415 assert!(resp.status.is_success());
416 let project: Value = resp.json();
417 let project_id = project["id"].as_str().unwrap();
418 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
419
420 // ── Insert a tier via SQL ──
421 let tier_id = sqlx::query_scalar::<_, uuid::Uuid>(
422 "INSERT INTO subscription_tiers (project_id, name, price_cents) \
423 VALUES ($1, 'Temp Tier', 800) RETURNING id",
424 )
425 .bind(project_uuid)
426 .fetch_one(&h.db)
427 .await
428 .unwrap();
429
430 // ── Verify it exists ──
431 let resp = h
432 .client
433 .get(&format!("/api/projects/{project_id}/tiers"))
434 .await;
435 let body: Value = resp.json();
436 let tiers = body["data"].as_array().unwrap();
437 assert_eq!(tiers.len(), 1, "Tier should exist before deletion");
438
439 let resp = h.client.delete(&format!("/api/tiers/{tier_id}")).await;
440 assert_eq!(
441 resp.status, 204,
442 "Delete tier should return 204, got {} {}",
443 resp.status, resp.text
444 );
445
446 // ── Verify tier is gone from list ──
447 let resp = h
448 .client
449 .get(&format!("/api/projects/{project_id}/tiers"))
450 .await;
451 let body: Value = resp.json();
452 let tiers = body["data"].as_array().unwrap();
453 assert_eq!(tiers.len(), 0, "Tier list should be empty after deletion");
454
455 // ── Verify hard delete (no subscriptions referenced it) ──
456 let count =
457 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM subscription_tiers WHERE id = $1")
458 .bind(tier_id)
459 .fetch_one(&h.db)
460 .await
461 .unwrap();
462 assert_eq!(count, 0, "Tier should be hard-deleted from database");
463 }
464
465 #[tokio::test]
466 async fn subscriber_tier_visibility() {
467 let mut h = TestHarness::new().await;
468
469 // Creator sets up a public project with a tier
470 let _user_id = h.create_creator("tiervis").await;
471
472 let resp = h
473 .client
474 .post_form("/api/projects", "slug=tier-vis&title=Visible+Tiers")
475 .await;
476 assert!(resp.status.is_success());
477 let project: Value = resp.json();
478 let project_id = project["id"].as_str().unwrap();
479 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
480
481 // Publish the project
482 h.client
483 .put_json(
484 &format!("/api/projects/{project_id}"),
485 r#"{"is_public": true}"#,
486 )
487 .await;
488
489 // Insert an active tier via SQL
490 sqlx::query(
491 "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \
492 VALUES ($1, 'Community', 'Join the community', 500)",
493 )
494 .bind(project_uuid)
495 .execute(&h.db)
496 .await
497 .unwrap();
498
499 // ── Log out so we are unauthenticated ──
500 h.client.post_form("/logout", "").await;
501
502 // ── Visit the public project page as anonymous user ──
503 let resp = h.client.get("/p/tier-vis").await;
504 assert_eq!(
505 resp.status, 200,
506 "Public project page should render, got {}",
507 resp.status
508 );
509 // The project page template should include the tier name
510 assert!(
511 resp.text.contains("Community"),
512 "Public project page should show the tier name 'Community'"
513 );
514 }
515
516 #[tokio::test]
517 async fn sandbox_tier_uses_fake_stripe_ids() {
518 let mut h = TestHarness::new().await;
519
520 // ── Create a sandbox account via POST /sandbox ──
521 h.client.fetch_csrf_token().await;
522 let resp = h.client.post_form("/sandbox", "").await;
523 assert!(
524 resp.status.is_redirection(),
525 "Sandbox creation should redirect, got {} {}",
526 resp.status,
527 resp.text
528 );
529
530 // Fetch CSRF for the new session
531 h.client.fetch_csrf_token().await;
532
533 // Find the sandbox user
534 let (sandbox_user_id, is_sandbox): (uuid::Uuid, bool) = sqlx::query_as(
535 "SELECT id, is_sandbox FROM users WHERE username LIKE 'sandbox_%' ORDER BY created_at DESC LIMIT 1",
536 )
537 .fetch_one(&h.db)
538 .await
539 .unwrap();
540 assert!(is_sandbox, "User should be a sandbox account");
541
542 // The sandbox user already has a demo project seeded. Find it.
543 let project_id: uuid::Uuid =
544 sqlx::query_scalar("SELECT id FROM projects WHERE user_id = $1 LIMIT 1")
545 .bind(sandbox_user_id)
546 .fetch_one(&h.db)
547 .await
548 .unwrap();
549
550 // ── Create a tier via the API (sandbox users get fake Stripe IDs) ──
551 let resp = h
552 .client
553 .post_json(
554 &format!("/api/projects/{project_id}/tiers"),
555 r#"{"name": "Sandbox Tier", "description": "Test tier", "price_cents": 500}"#,
556 )
557 .await;
558 assert!(
559 resp.status.is_success(),
560 "Sandbox tier creation should succeed, got {} {}",
561 resp.status,
562 resp.text
563 );
564 let tier: Value = resp.json();
565 let tier_id = tier["id"].as_str().unwrap();
566
567 // ── Verify the tier has sandbox_ prefixed Stripe IDs ──
568 let (prod_id, price_id): (Option<String>, Option<String>) = sqlx::query_as(
569 "SELECT stripe_product_id, stripe_price_id FROM subscription_tiers WHERE id = $1::uuid",
570 )
571 .bind(tier_id)
572 .fetch_one(&h.db)
573 .await
574 .unwrap();
575
576 let prod_id = prod_id.expect("Sandbox tier should have stripe_product_id");
577 let price_id = price_id.expect("Sandbox tier should have stripe_price_id");
578 assert!(
579 prod_id.starts_with("sandbox_prod_"),
580 "Sandbox product ID should start with 'sandbox_prod_', got: {prod_id}"
581 );
582 assert!(
583 price_id.starts_with("sandbox_price_"),
584 "Sandbox price ID should start with 'sandbox_price_', got: {price_id}"
585 );
586 }
587
588 #[tokio::test]
589 async fn non_owner_cannot_manage_tiers() {
590 let mut h = TestHarness::new().await;
591
592 // Creator creates a project
593 let creator_id = h
594 .signup("tierowner", "tierowner@example.com", "password123")
595 .await;
596 h.grant_creator(creator_id).await;
597 h.client.post_form("/logout", "").await;
598 h.login("tierowner", "password123").await;
599
600 let resp = h
601 .client
602 .post_form("/api/projects", "slug=owned&title=Owned+Project")
603 .await;
604 let project: Value = resp.json();
605 let project_id = project["id"].as_str().unwrap();
606 let project_uuid: uuid::Uuid = project_id.parse().unwrap();
607
608 // Insert a tier via SQL
609 let tier_id = sqlx::query_scalar::<_, uuid::Uuid>(
610 "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \
611 VALUES ($1, $2, $3, $4) RETURNING id",
612 )
613 .bind(project_uuid)
614 .bind("Owner Tier")
615 .bind(None::<String>)
616 .bind(1000)
617 .fetch_one(&h.db)
618 .await
619 .unwrap();
620
621 // Sign in as a different user
622 h.client.post_form("/logout", "").await;
623 let other_id = h
624 .signup("intruder", "intruder@example.com", "password456")
625 .await;
626 h.grant_creator(other_id).await;
627 h.client.post_form("/logout", "").await;
628 h.login("intruder", "password456").await;
629
630 // Non-owner should not be able to list tiers
631 let resp = h
632 .client
633 .get(&format!("/api/projects/{project_id}/tiers"))
634 .await;
635 assert_eq!(
636 resp.status, 403,
637 "Non-owner listing tiers should return 403, got {}",
638 resp.status
639 );
640
641 // Non-owner should not be able to update tier
642 let resp = h
643 .client
644 .put_json(
645 &format!("/api/tiers/{tier_id}"),
646 r#"{"name": "Hacked", "description": null, "is_active": true}"#,
647 )
648 .await;
649 assert_eq!(
650 resp.status, 403,
651 "Non-owner updating tier should return 403, got {}",
652 resp.status
653 );
654
655 // Non-owner should not be able to delete tier
656 let resp = h.client.delete(&format!("/api/tiers/{tier_id}")).await;
657 assert_eq!(
658 resp.status, 403,
659 "Non-owner deleting tier should return 403, got {}",
660 resp.status
661 );
662 }
663