Skip to main content

max / makenotwork

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