Skip to main content

max / makenotwork

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