Skip to main content

max / makenotwork

18.7 KB · 657 lines History Blame Raw
1 //! Fan collections integration tests.
2 //!
3 //! Tests the collection CRUD lifecycle: create, update, delete, add/remove items,
4 //! reorder, public/private visibility, ownership checks, and profile display.
5
6 use crate::harness::TestHarness;
7
8 // ── Collection CRUD ──
9
10 #[tokio::test]
11 async fn create_collection() {
12 let mut h = TestHarness::new().await;
13 h.signup("colluser", "coll@example.com", "password123").await;
14
15 let resp = h
16 .client
17 .post_json(
18 "/api/collections",
19 r#"{"slug": "my-list", "title": "My Reading List", "description": "Good reads", "is_public": true}"#,
20 )
21 .await;
22 assert_eq!(resp.status, 201, "Create collection: {}", resp.text);
23 let body: serde_json::Value = resp.json();
24 assert_eq!(body["title"], "My Reading List");
25 assert_eq!(body["slug"], "my-list");
26 assert_eq!(body["is_public"], true);
27 assert!(body["id"].as_str().is_some());
28 }
29
30 #[tokio::test]
31 async fn create_collection_validates_slug() {
32 let mut h = TestHarness::new().await;
33 h.signup("slugval", "slugval@example.com", "password123").await;
34
35 // Invalid slug (contains spaces)
36 let resp = h
37 .client
38 .post_json(
39 "/api/collections",
40 r#"{"slug": "bad slug!", "title": "Title"}"#,
41 )
42 .await;
43 assert!(
44 resp.status.is_client_error(),
45 "Invalid slug should be rejected: {} {}",
46 resp.status,
47 resp.text
48 );
49 }
50
51 #[tokio::test]
52 async fn create_collection_enforces_limit() {
53 let mut h = TestHarness::new().await;
54 let user_id = h.signup("limituser", "limit@example.com", "password123").await;
55
56 // Seed 50 collections directly via SQL to hit the limit
57 for i in 0..50 {
58 sqlx::query("INSERT INTO collections (user_id, slug, title) VALUES ($1, $2, $3)")
59 .bind(user_id)
60 .bind(format!("coll-{}", i))
61 .bind(format!("Collection {}", i))
62 .execute(&h.db)
63 .await
64 .unwrap();
65 }
66
67 let resp = h
68 .client
69 .post_json(
70 "/api/collections",
71 r#"{"slug": "one-too-many", "title": "Overflow"}"#,
72 )
73 .await;
74 assert!(
75 resp.status.is_client_error(),
76 "Should reject at limit: {} {}",
77 resp.status,
78 resp.text
79 );
80 assert!(
81 resp.text.contains("50"),
82 "Error should mention limit: {}",
83 resp.text
84 );
85 }
86
87 #[tokio::test]
88 async fn update_collection() {
89 let mut h = TestHarness::new().await;
90 h.signup("upduser", "upd@example.com", "password123").await;
91
92 let resp = h
93 .client
94 .post_json(
95 "/api/collections",
96 r#"{"slug": "orig", "title": "Original", "is_public": false}"#,
97 )
98 .await;
99 assert_eq!(resp.status, 201);
100 let body: serde_json::Value = resp.json();
101 let id = body["id"].as_str().unwrap();
102
103 let resp = h
104 .client
105 .put_json(
106 &format!("/api/collections/{}", id),
107 r#"{"title": "Updated Title", "description": "Now with desc", "is_public": true}"#,
108 )
109 .await;
110 assert!(resp.status.is_success(), "Update failed: {} {}", resp.status, resp.text);
111 let body: serde_json::Value = resp.json();
112 assert_eq!(body["title"], "Updated Title");
113 assert_eq!(body["is_public"], true);
114 }
115
116 #[tokio::test]
117 async fn delete_collection() {
118 let mut h = TestHarness::new().await;
119 h.signup("deluser", "del@example.com", "password123").await;
120
121 let resp = h
122 .client
123 .post_json(
124 "/api/collections",
125 r#"{"slug": "to-delete", "title": "Delete Me"}"#,
126 )
127 .await;
128 let body: serde_json::Value = resp.json();
129 let id = body["id"].as_str().unwrap();
130
131 let resp = h.client.delete(&format!("/api/collections/{}", id)).await;
132 assert_eq!(resp.status, 204, "Delete failed: {}", resp.text);
133
134 // Verify it's gone — re-deleting should 404
135 let resp = h.client.delete(&format!("/api/collections/{}", id)).await;
136 assert_eq!(resp.status, 404);
137 }
138
139 // ── Add / remove items ──
140
141 #[tokio::test]
142 async fn add_remove_item() {
143 let mut h = TestHarness::new().await;
144
145 // Create a creator with a public item
146 let _seller_id = h.create_creator("addrem").await;
147 let project: serde_json::Value = h
148 .client
149 .post_form("/api/projects", "slug=ar-proj&title=AR+Project")
150 .await
151 .json();
152 let project_id = project["id"].as_str().unwrap();
153 let item: serde_json::Value = h
154 .client
155 .post_form(
156 &format!("/api/projects/{}/items", project_id),
157 "title=Test+Item&price_cents=100&item_type=digital",
158 )
159 .await
160 .json();
161 let item_id = item["id"].as_str().unwrap();
162 h.publish_project_and_item(project_id, item_id).await;
163
164 // Create a collection
165 let resp = h
166 .client
167 .post_json(
168 "/api/collections",
169 r#"{"slug": "my-favs", "title": "Favourites", "is_public": true}"#,
170 )
171 .await;
172 assert_eq!(resp.status, 201);
173 let coll: serde_json::Value = resp.json();
174 let coll_id = coll["id"].as_str().unwrap();
175
176 // Add item
177 let resp = h
178 .client
179 .post_json(
180 &format!("/api/collections/{}/items/{}", coll_id, item_id),
181 "{}",
182 )
183 .await;
184 assert_eq!(resp.status, 204, "Add item failed: {}", resp.text);
185
186 // Adding again is idempotent
187 let resp = h
188 .client
189 .post_json(
190 &format!("/api/collections/{}/items/{}", coll_id, item_id),
191 "{}",
192 )
193 .await;
194 assert_eq!(resp.status, 204);
195
196 // Remove item
197 let resp = h
198 .client
199 .delete(&format!("/api/collections/{}/items/{}", coll_id, item_id))
200 .await;
201 assert_eq!(resp.status, 204, "Remove item failed: {}", resp.text);
202 }
203
204 #[tokio::test]
205 async fn add_item_enforces_limit() {
206 let mut h = TestHarness::new().await;
207
208 let _seller_id = h.create_creator("limitseller").await;
209 let project: serde_json::Value = h
210 .client
211 .post_form("/api/projects", "slug=lim-proj&title=LimitProject")
212 .await
213 .json();
214 let project_id = project["id"].as_str().unwrap();
215
216 // Create a collection
217 let resp = h
218 .client
219 .post_json(
220 "/api/collections",
221 r#"{"slug": "big-list", "title": "Big List"}"#,
222 )
223 .await;
224 let coll: serde_json::Value = resp.json();
225 let coll_id = coll["id"].as_str().unwrap();
226 let coll_uuid: uuid::Uuid = coll_id.parse().unwrap();
227
228 // Seed 200 items directly and add them to the collection
229 for i in 0..200 {
230 let item_id: uuid::Uuid = sqlx::query_scalar(
231 "INSERT INTO items (project_id, title, price_cents, item_type, is_public, slug) VALUES ($1, $2, 0, 'digital', true, $3) RETURNING id",
232 )
233 .bind(project_id.parse::<uuid::Uuid>().unwrap())
234 .bind(format!("Item {}", i))
235 .bind(format!("item-{}", i))
236 .fetch_one(&h.db)
237 .await
238 .unwrap();
239
240 sqlx::query("INSERT INTO collection_items (collection_id, item_id, position) VALUES ($1, $2, $3)")
241 .bind(coll_uuid)
242 .bind(item_id)
243 .bind(i)
244 .execute(&h.db)
245 .await
246 .unwrap();
247 }
248
249 // Create one more public item via API
250 let extra: serde_json::Value = h
251 .client
252 .post_form(
253 &format!("/api/projects/{}/items", project_id),
254 "title=Extra+Item&price_cents=0&item_type=digital",
255 )
256 .await
257 .json();
258 let extra_id = extra["id"].as_str().unwrap();
259 h.client
260 .put_form(&format!("/api/items/{}", extra_id), "is_public=true")
261 .await;
262
263 // Should be rejected at 200
264 let resp = h
265 .client
266 .post_json(
267 &format!("/api/collections/{}/items/{}", coll_id, extra_id),
268 "{}",
269 )
270 .await;
271 assert!(
272 resp.status.is_client_error(),
273 "Should reject at item limit: {} {}",
274 resp.status,
275 resp.text
276 );
277 }
278
279 #[tokio::test]
280 async fn add_nonexistent_item_rejected() {
281 let mut h = TestHarness::new().await;
282 h.signup("noitem", "noitem@example.com", "password123").await;
283
284 let resp = h
285 .client
286 .post_json(
287 "/api/collections",
288 r#"{"slug": "empty", "title": "Empty"}"#,
289 )
290 .await;
291 let coll: serde_json::Value = resp.json();
292 let coll_id = coll["id"].as_str().unwrap();
293
294 let fake_id = uuid::Uuid::new_v4();
295 let resp = h
296 .client
297 .post_json(
298 &format!("/api/collections/{}/items/{}", coll_id, fake_id),
299 "{}",
300 )
301 .await;
302 assert_eq!(resp.status, 404, "Nonexistent item should 404: {}", resp.text);
303 }
304
305 #[tokio::test]
306 async fn add_draft_item_rejected() {
307 let mut h = TestHarness::new().await;
308
309 let _seller_id = h.create_creator("draftblock").await;
310 let project: serde_json::Value = h
311 .client
312 .post_form("/api/projects", "slug=draft-proj&title=DraftProject")
313 .await
314 .json();
315 let project_id = project["id"].as_str().unwrap();
316 let item: serde_json::Value = h
317 .client
318 .post_form(
319 &format!("/api/projects/{}/items", project_id),
320 "title=Draft+Item&price_cents=100&item_type=digital",
321 )
322 .await
323 .json();
324 let item_id = item["id"].as_str().unwrap();
325 // Explicitly unpublish the item (items default to is_public=true)
326 h.client
327 .put_form(&format!("/api/items/{}", item_id), "is_public=false")
328 .await;
329
330 let resp = h
331 .client
332 .post_json(
333 "/api/collections",
334 r#"{"slug": "draft-coll", "title": "Draft Coll"}"#,
335 )
336 .await;
337 let coll: serde_json::Value = resp.json();
338 let coll_id = coll["id"].as_str().unwrap();
339
340 let resp = h
341 .client
342 .post_json(
343 &format!("/api/collections/{}/items/{}", coll_id, item_id),
344 "{}",
345 )
346 .await;
347 assert!(
348 resp.status.is_client_error(),
349 "Draft item should be rejected: {} {}",
350 resp.status,
351 resp.text
352 );
353 assert!(
354 resp.text.contains("public"),
355 "Error should mention public: {}",
356 resp.text
357 );
358 }
359
360 // ── Public collection page ──
361
362 #[tokio::test]
363 async fn public_collection_page_visible() {
364 let mut h = TestHarness::new().await;
365
366 let _creator_id = h.create_creator("pageowner").await;
367 let project: serde_json::Value = h
368 .client
369 .post_form("/api/projects", "slug=page-proj&title=PageProject")
370 .await
371 .json();
372 let project_id = project["id"].as_str().unwrap();
373 let item: serde_json::Value = h
374 .client
375 .post_form(
376 &format!("/api/projects/{}/items", project_id),
377 "title=Page+Item&price_cents=0&item_type=digital",
378 )
379 .await
380 .json();
381 let item_id = item["id"].as_str().unwrap();
382 h.publish_project_and_item(project_id, item_id).await;
383
384 // Create public collection and add item
385 let resp = h
386 .client
387 .post_json(
388 "/api/collections",
389 r#"{"slug": "my-picks", "title": "My Picks", "is_public": true}"#,
390 )
391 .await;
392 let coll: serde_json::Value = resp.json();
393 let coll_id = coll["id"].as_str().unwrap();
394 h.client
395 .post_json(
396 &format!("/api/collections/{}/items/{}", coll_id, item_id),
397 "{}",
398 )
399 .await;
400
401 // Log out — anonymous user should see the page
402 h.client.post_form("/logout", "").await;
403
404 let resp = h.client.get("/c/pageowner/my-picks").await;
405 assert_eq!(resp.status, 200, "Public collection page: {}", resp.text);
406 assert!(resp.text.contains("My Picks"), "Should contain title");
407 assert!(resp.text.contains("Page Item"), "Should contain item title");
408 }
409
410 #[tokio::test]
411 async fn private_collection_page_hidden() {
412 let mut h = TestHarness::new().await;
413 h.signup("privowner", "priv@example.com", "password123").await;
414
415 let resp = h
416 .client
417 .post_json(
418 "/api/collections",
419 r#"{"slug": "secret", "title": "Secret List", "is_public": false}"#,
420 )
421 .await;
422 assert_eq!(resp.status, 201);
423
424 // Log out — anonymous user should get 404
425 h.client.post_form("/logout", "").await;
426
427 let resp = h.client.get("/c/privowner/secret").await;
428 assert_eq!(
429 resp.status, 404,
430 "Private collection should be 404 for non-owner: {}",
431 resp.text
432 );
433 }
434
435 #[tokio::test]
436 async fn collection_slug_unique_per_user() {
437 let mut h = TestHarness::new().await;
438
439 // User 1 creates collection with slug "shared-slug"
440 h.signup("user1", "user1@example.com", "password123").await;
441 let resp = h
442 .client
443 .post_json(
444 "/api/collections",
445 r#"{"slug": "shared-slug", "title": "User 1 Collection"}"#,
446 )
447 .await;
448 assert_eq!(resp.status, 201);
449
450 // User 2 can create a collection with the same slug
451 h.client.post_form("/logout", "").await;
452 h.signup("user2", "user2@example.com", "password123").await;
453 let resp = h
454 .client
455 .post_json(
456 "/api/collections",
457 r#"{"slug": "shared-slug", "title": "User 2 Collection"}"#,
458 )
459 .await;
460 assert_eq!(
461 resp.status, 201,
462 "Same slug for different user should work: {} {}",
463 resp.status, resp.text
464 );
465
466 // User 2 cannot create a duplicate slug
467 let resp = h
468 .client
469 .post_json(
470 "/api/collections",
471 r#"{"slug": "shared-slug", "title": "Duplicate"}"#,
472 )
473 .await;
474 assert!(
475 resp.status.is_client_error(),
476 "Duplicate slug for same user should fail: {}",
477 resp.text
478 );
479 }
480
481 // ── Ownership checks ──
482
483 #[tokio::test]
484 async fn owner_only_mutations() {
485 let mut h = TestHarness::new().await;
486
487 // User A creates a collection
488 h.signup("ownerA", "ownerA@example.com", "password123").await;
489 let resp = h
490 .client
491 .post_json(
492 "/api/collections",
493 r#"{"slug": "owned", "title": "Owned"}"#,
494 )
495 .await;
496 let body: serde_json::Value = resp.json();
497 let coll_id = body["id"].as_str().unwrap().to_string();
498
499 // User B tries to update
500 h.client.post_form("/logout", "").await;
501 h.signup("ownerB", "ownerB@example.com", "password123").await;
502
503 let resp = h
504 .client
505 .put_json(
506 &format!("/api/collections/{}", coll_id),
507 r#"{"title": "Hijacked", "is_public": true}"#,
508 )
509 .await;
510 assert_eq!(resp.status, 403, "Non-owner update should be 403: {}", resp.text);
511
512 // User B tries to delete
513 let resp = h
514 .client
515 .delete(&format!("/api/collections/{}", coll_id))
516 .await;
517 assert_eq!(resp.status, 403, "Non-owner delete should be 403: {}", resp.text);
518 }
519
520 // ── User profile shows public collections ──
521
522 #[tokio::test]
523 async fn user_profile_shows_public_collections() {
524 let mut h = TestHarness::new().await;
525 h.signup("profuser", "profuser@example.com", "password123").await;
526
527 // Create a public collection
528 let resp = h
529 .client
530 .post_json(
531 "/api/collections",
532 r#"{"slug": "visible-list", "title": "Visible List", "is_public": true}"#,
533 )
534 .await;
535 assert_eq!(resp.status, 201);
536
537 // Create a private collection
538 h.client
539 .post_json(
540 "/api/collections",
541 r#"{"slug": "hidden-list", "title": "Hidden List", "is_public": false}"#,
542 )
543 .await;
544
545 // Check profile page
546 let resp = h.client.get("/u/profuser").await;
547 assert_eq!(resp.status, 200);
548 assert!(
549 resp.text.contains("Visible List"),
550 "Public collection should appear on profile"
551 );
552 assert!(
553 !resp.text.contains("Hidden List"),
554 "Private collection should NOT appear on profile"
555 );
556 }
557
558 // ── Reorder items ──
559
560 #[tokio::test]
561 async fn reorder_items() {
562 let mut h = TestHarness::new().await;
563
564 let _creator_id = h.create_creator("reorder").await;
565 let project: serde_json::Value = h
566 .client
567 .post_form("/api/projects", "slug=reord-proj&title=ReorderProject")
568 .await
569 .json();
570 let project_id = project["id"].as_str().unwrap();
571
572 // Create two public items
573 let item_a: serde_json::Value = h
574 .client
575 .post_form(
576 &format!("/api/projects/{}/items", project_id),
577 "title=Item+A&price_cents=0&item_type=digital",
578 )
579 .await
580 .json();
581 let item_a_id = item_a["id"].as_str().unwrap();
582
583 let item_b: serde_json::Value = h
584 .client
585 .post_form(
586 &format!("/api/projects/{}/items", project_id),
587 "title=Item+B&price_cents=0&item_type=digital",
588 )
589 .await
590 .json();
591 let item_b_id = item_b["id"].as_str().unwrap();
592
593 // Publish both
594 h.client
595 .put_json(
596 &format!("/api/projects/{}", project_id),
597 r#"{"is_public": true}"#,
598 )
599 .await;
600 h.client
601 .put_form(&format!("/api/items/{}", item_a_id), "is_public=true")
602 .await;
603 h.client
604 .put_form(&format!("/api/items/{}", item_b_id), "is_public=true")
605 .await;
606
607 // Create collection and add both items (A then B)
608 let resp = h
609 .client
610 .post_json(
611 "/api/collections",
612 r#"{"slug": "ordered", "title": "Ordered", "is_public": true}"#,
613 )
614 .await;
615 let coll: serde_json::Value = resp.json();
616 let coll_id = coll["id"].as_str().unwrap();
617
618 h.client
619 .post_json(
620 &format!("/api/collections/{}/items/{}", coll_id, item_a_id),
621 "{}",
622 )
623 .await;
624 h.client
625 .post_json(
626 &format!("/api/collections/{}/items/{}", coll_id, item_b_id),
627 "{}",
628 )
629 .await;
630
631 // Reorder: B before A
632 let reorder_body = format!(
633 r#"{{"item_ids": ["{}","{}"]}}"#,
634 item_b_id, item_a_id
635 );
636 let resp = h
637 .client
638 .put_json(
639 &format!("/api/collections/{}/items/reorder", coll_id),
640 &reorder_body,
641 )
642 .await;
643 assert_eq!(resp.status, 204, "Reorder failed: {}", resp.text);
644
645 // Verify order on the public page — Item B should appear before Item A
646 let resp = h.client.get("/c/reorder/ordered").await;
647 assert_eq!(resp.status, 200);
648 let pos_b = resp.text.find("Item B").expect("Item B not found");
649 let pos_a = resp.text.find("Item A").expect("Item A not found");
650 assert!(
651 pos_b < pos_a,
652 "Item B (pos {}) should appear before Item A (pos {})",
653 pos_b,
654 pos_a
655 );
656 }
657