Skip to main content

max / makenotwork

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