Skip to main content

max / makenotwork

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