Skip to main content

max / makenotwork

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