Skip to main content

max / makenotwork

13.7 KB · 505 lines History Blame Raw
1 //! Bundle workflow tests, create, add, remove, toggle listed, access.
2
3 use crate::harness::TestHarness;
4 use makenotwork::db;
5 use serde_json::{Value, json};
6
7 /// Helper: create a creator with a bundle item and a child item in the same project.
8 /// Returns (user_id, project_id, bundle_id, child_id). Creator stays logged in.
9 async fn setup_bundle(h: &mut TestHarness) -> (db::UserId, String, String, String) {
10 let user_id = h.create_creator("bundler").await;
11
12 let resp = h
13 .client
14 .post_form("/api/projects", "slug=bundle-proj&title=Bundle+Project")
15 .await;
16 assert!(
17 resp.status.is_success(),
18 "Create project failed: {}",
19 resp.text
20 );
21 let project: Value = resp.json();
22 let project_id = project["id"].as_str().unwrap().to_string();
23
24 // Create bundle item
25 let resp = h
26 .client
27 .post_form(
28 &format!("/api/projects/{project_id}/items"),
29 "title=My+Bundle&item_type=bundle&price_cents=1999",
30 )
31 .await;
32 assert!(
33 resp.status.is_success(),
34 "Create bundle failed: {}",
35 resp.text
36 );
37 let bundle: Value = resp.json();
38 let bundle_id = bundle["id"].as_str().unwrap().to_string();
39
40 // Create a normal item in the same project
41 let resp = h
42 .client
43 .post_form(
44 &format!("/api/projects/{project_id}/items"),
45 "title=Child+Item&item_type=digital&price_cents=0",
46 )
47 .await;
48 assert!(
49 resp.status.is_success(),
50 "Create child item failed: {}",
51 resp.text
52 );
53 let child: Value = resp.json();
54 let child_id = child["id"].as_str().unwrap().to_string();
55
56 (user_id, project_id, bundle_id, child_id)
57 }
58
59 // Add to bundle
60
61 #[tokio::test]
62 async fn bundle_add_item() {
63 let mut h = TestHarness::new().await;
64 let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await;
65
66 let resp = h
67 .client
68 .post_json(
69 &format!("/api/items/{bundle_id}/bundle/add"),
70 &json!({"item_id": child_id}).to_string(),
71 )
72 .await;
73 assert!(
74 resp.status.is_success(),
75 "Bundle add failed: {} {}",
76 resp.status,
77 resp.text
78 );
79 }
80
81 #[tokio::test]
82 async fn bundle_add_non_owner_rejected() {
83 let mut h = TestHarness::new().await;
84 let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await;
85
86 // Log out creator, sign in as intruder
87 h.client.post_form("/logout", "").await;
88 h.create_creator("intruder").await;
89
90 let resp = h
91 .client
92 .post_json(
93 &format!("/api/items/{bundle_id}/bundle/add"),
94 &json!({"item_id": child_id}).to_string(),
95 )
96 .await;
97 assert!(
98 resp.status == 403 || resp.status == 404,
99 "Non-owner bundle add should be rejected: {} {}",
100 resp.status,
101 resp.text
102 );
103 }
104
105 #[tokio::test]
106 async fn bundle_add_non_bundle_item_rejected() {
107 let mut h = TestHarness::new().await;
108 let (_, project_id, _bundle_id, child_id) = setup_bundle(&mut h).await;
109
110 // Create another normal item
111 let resp = h
112 .client
113 .post_form(
114 &format!("/api/projects/{project_id}/items"),
115 "title=Another&item_type=digital&price_cents=0",
116 )
117 .await;
118 let another: Value = resp.json();
119 let another_id = another["id"].as_str().unwrap().to_string();
120
121 // Try to add to a non-bundle item
122 let resp = h
123 .client
124 .post_json(
125 &format!("/api/items/{child_id}/bundle/add"),
126 &json!({"item_id": another_id}).to_string(),
127 )
128 .await;
129 assert!(
130 resp.status.is_client_error(),
131 "Adding to non-bundle item should be rejected: {} {}",
132 resp.status,
133 resp.text
134 );
135 }
136
137 // Remove from bundle
138
139 #[tokio::test]
140 async fn bundle_remove_item() {
141 let mut h = TestHarness::new().await;
142 let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await;
143
144 // First add
145 h.client
146 .post_json(
147 &format!("/api/items/{bundle_id}/bundle/add"),
148 &json!({"item_id": child_id}).to_string(),
149 )
150 .await;
151
152 // Then remove
153 let resp = h
154 .client
155 .delete(&format!("/api/items/{bundle_id}/bundle/{child_id}"))
156 .await;
157 assert!(
158 resp.status.is_success(),
159 "Bundle remove failed: {} {}",
160 resp.status,
161 resp.text
162 );
163 }
164
165 #[tokio::test]
166 async fn bundle_remove_not_member_is_idempotent() {
167 let mut h = TestHarness::new().await;
168 let (_, project_id, bundle_id, _child_id) = setup_bundle(&mut h).await;
169
170 // Create item but don't add to bundle
171 let resp = h
172 .client
173 .post_form(
174 &format!("/api/projects/{project_id}/items"),
175 "title=NotInBundle&item_type=digital&price_cents=0",
176 )
177 .await;
178 let other: Value = resp.json();
179 let other_id = other["id"].as_str().unwrap().to_string();
180
181 // Removing a non-member is idempotent (DELETE matches 0 rows, returns OK)
182 let resp = h
183 .client
184 .delete(&format!("/api/items/{bundle_id}/bundle/{other_id}"))
185 .await;
186 assert!(
187 resp.status.is_success(),
188 "Idempotent remove should succeed: {} {}",
189 resp.status,
190 resp.text
191 );
192 }
193
194 // Toggle listed
195
196 #[tokio::test]
197 async fn bundle_toggle_listed() {
198 let mut h = TestHarness::new().await;
199 let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await;
200
201 // Add child to bundle
202 h.client
203 .post_json(
204 &format!("/api/items/{bundle_id}/bundle/add"),
205 &json!({"item_id": child_id}).to_string(),
206 )
207 .await;
208
209 // Toggle listed to false
210 let resp = h
211 .client
212 .put_json(
213 &format!("/api/items/{bundle_id}/bundle/{child_id}/listed"),
214 r#"{"listed": false}"#,
215 )
216 .await;
217 assert!(
218 resp.status.is_success(),
219 "Toggle listed failed: {} {}",
220 resp.status,
221 resp.text
222 );
223
224 // Toggle listed back to true
225 let resp = h
226 .client
227 .put_json(
228 &format!("/api/items/{bundle_id}/bundle/{child_id}/listed"),
229 r#"{"listed": true}"#,
230 )
231 .await;
232 assert!(
233 resp.status.is_success(),
234 "Toggle listed back failed: {} {}",
235 resp.status,
236 resp.text
237 );
238 }
239
240 // Create child
241
242 #[tokio::test]
243 async fn bundle_create_child() {
244 let mut h = TestHarness::new().await;
245 let (_, _, bundle_id, _) = setup_bundle(&mut h).await;
246
247 let resp = h
248 .client
249 .post_json(
250 &format!("/api/items/{bundle_id}/bundle/create-child"),
251 r#"{"title": "New Track"}"#,
252 )
253 .await;
254 assert!(
255 resp.status.is_success(),
256 "Create child failed: {} {}",
257 resp.status,
258 resp.text
259 );
260 let data: Value = resp.json();
261 assert!(data["item_id"].is_string(), "Should return item_id");
262 assert_eq!(data["title"], "New Track");
263 }
264
265 #[tokio::test]
266 async fn bundle_create_child_empty_title_rejected() {
267 let mut h = TestHarness::new().await;
268 let (_, _, bundle_id, _) = setup_bundle(&mut h).await;
269
270 let resp = h
271 .client
272 .post_json(
273 &format!("/api/items/{bundle_id}/bundle/create-child"),
274 r#"{"title": ""}"#,
275 )
276 .await;
277 assert!(
278 resp.status.is_client_error(),
279 "Empty title should be rejected: {} {}",
280 resp.status,
281 resp.text
282 );
283 }
284
285 // Cross-project rejection
286
287 #[tokio::test]
288 async fn bundle_add_cross_project_rejected() {
289 let mut h = TestHarness::new().await;
290 let (_, _, bundle_id, _) = setup_bundle(&mut h).await;
291
292 // Create a second project with an item
293 let resp = h
294 .client
295 .post_form("/api/projects", "slug=other-proj&title=Other")
296 .await;
297 let project2: Value = resp.json();
298 let project2_id = project2["id"].as_str().unwrap().to_string();
299
300 let resp = h
301 .client
302 .post_form(
303 &format!("/api/projects/{project2_id}/items"),
304 "title=Other+Item&item_type=digital&price_cents=0",
305 )
306 .await;
307 let other: Value = resp.json();
308 let other_id = other["id"].as_str().unwrap().to_string();
309
310 // Try to add item from different project to bundle
311 let resp = h
312 .client
313 .post_json(
314 &format!("/api/items/{bundle_id}/bundle/add"),
315 &json!({"item_id": other_id}).to_string(),
316 )
317 .await;
318 assert!(
319 resp.status.is_client_error(),
320 "Cross-project bundle add should be rejected: {} {}",
321 resp.status,
322 resp.text
323 );
324 }
325
326 // Bundle purchase grants access to children
327
328 /// Free bundles are claimed via /api/library/add which calls
329 /// grant_bundle_items() to grant access to all child items.
330 #[tokio::test]
331 async fn bundle_free_claim_grants_child_access() {
332 let mut h = TestHarness::new().await;
333 let (_, project_id, bundle_id, child_id) = setup_bundle(&mut h).await;
334
335 h.client
336 .post_json(
337 &format!("/api/items/{bundle_id}/bundle/add"),
338 &json!({"item_id": child_id}).to_string(),
339 )
340 .await;
341 h.client
342 .put_form(
343 &format!("/api/items/{bundle_id}"),
344 "price_cents=0&is_public=true",
345 )
346 .await;
347 h.client
348 .put_form(&format!("/api/items/{child_id}"), "is_public=true")
349 .await;
350 h.client
351 .put_json(
352 &format!("/api/projects/{project_id}"),
353 r#"{"is_public": true}"#,
354 )
355 .await;
356
357 h.client.post_form("/logout", "").await;
358 let buyer_id = h
359 .signup("libbundle", "libbundle@test.com", "password123")
360 .await;
361
362 h.client
363 .post_form(&format!("/api/library/add/{bundle_id}"), "")
364 .await;
365
366 let child_tx: i64 = sqlx::query_scalar(
367 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
368 )
369 .bind(buyer_id)
370 .bind(&child_id)
371 .fetch_one(&h.db)
372 .await
373 .unwrap();
374 assert_eq!(
375 child_tx, 1,
376 "Library add should grant child access via bundle"
377 );
378 }
379
380 /// Paid bundle checkout via mock Stripe → webhook completes → children granted.
381 #[tokio::test]
382 async fn bundle_paid_checkout_grants_child_access() {
383 use std::collections::HashMap;
384
385 let mut h = TestHarness::with_mocks().await;
386 let (user_id, project_id, bundle_id, child_id) = setup_bundle(&mut h).await;
387
388 // Connect Stripe for the seller
389 h.connect_stripe(user_id, "acct_mock_bundler").await;
390
391 // Add child to bundle
392 h.client
393 .post_json(
394 &format!("/api/items/{bundle_id}/bundle/add"),
395 &json!({"item_id": child_id}).to_string(),
396 )
397 .await;
398
399 // Set bundle to paid ($19.99) and publish
400 h.client
401 .put_form(
402 &format!("/api/items/{bundle_id}"),
403 "price_cents=1999&is_public=true",
404 )
405 .await;
406 h.client
407 .put_form(&format!("/api/items/{child_id}"), "is_public=true")
408 .await;
409 h.client
410 .put_json(
411 &format!("/api/projects/{project_id}"),
412 r#"{"is_public": true}"#,
413 )
414 .await;
415 h.client.post_form("/logout", "").await;
416
417 // Buyer initiates checkout
418 let buyer_id = h
419 .signup("bundlebuyer", "bundlebuyer@test.com", "password123")
420 .await;
421 let resp = h
422 .client
423 .post_form(
424 &format!("/stripe/checkout/{bundle_id}"),
425 "share_contact=false",
426 )
427 .await;
428 assert!(
429 resp.status.is_redirection() || resp.status.is_success(),
430 "Bundle checkout should redirect: {} {}",
431 resp.status,
432 resp.text
433 );
434
435 // Find pending transaction
436 let session_id: String = sqlx::query_scalar(
437 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
438 )
439 .bind(buyer_id)
440 .fetch_one(&h.db)
441 .await
442 .unwrap();
443
444 // Fire checkout.session.completed webhook
445 let mut meta = HashMap::new();
446 meta.insert("buyer_id".to_string(), buyer_id.to_string());
447 meta.insert("seller_id".to_string(), user_id.to_string());
448 meta.insert("item_id".to_string(), bundle_id.clone());
449 let session = serde_json::json!({
450 "id": session_id,
451 "object": "checkout_session",
452 "mode": "payment",
453 "metadata": meta,
454 "payment_intent": "pi_bundle_001",
455 });
456 let payload = serde_json::json!({
457 "id": "evt_bundle_001",
458 "type": "checkout.session.completed",
459 "data": {"object": session},
460 })
461 .to_string();
462 let signature = crate::harness::stripe::sign_webhook_payload(
463 &payload,
464 crate::harness::stripe::TEST_WEBHOOK_SECRET,
465 );
466 let resp = h
467 .client
468 .request_with_headers(
469 "POST",
470 "/stripe/webhook",
471 Some(&payload),
472 &[
473 ("stripe-signature", &signature),
474 ("content-type", "application/json"),
475 ],
476 )
477 .await;
478 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
479
480 // Verify bundle transaction completed
481 let status: String = sqlx::query_scalar(
482 "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
483 )
484 .bind(buyer_id)
485 .bind(&bundle_id)
486 .fetch_one(&h.db)
487 .await
488 .unwrap();
489 assert_eq!(status, "completed");
490
491 // Verify child item access granted via grant_bundle_items
492 let child_tx: i64 = sqlx::query_scalar(
493 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
494 )
495 .bind(buyer_id)
496 .bind(&child_id)
497 .fetch_one(&h.db)
498 .await
499 .unwrap();
500 assert_eq!(
501 child_tx, 1,
502 "Bundle purchase should grant access to child item"
503 );
504 }
505