Skip to main content

max / makenotwork

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