//! Fan collections integration tests. //! //! Tests the collection CRUD lifecycle: create, update, delete, add/remove items, //! reorder, public/private visibility, ownership checks, and profile display. use crate::harness::TestHarness; // ── Collection CRUD ── #[tokio::test] async fn create_collection() { let mut h = TestHarness::new().await; h.signup("colluser", "coll@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "my-list", "title": "My Reading List", "description": "Good reads", "is_public": true}"#, ) .await; assert_eq!(resp.status, 201, "Create collection: {}", resp.text); let body: serde_json::Value = resp.json(); assert_eq!(body["title"], "My Reading List"); assert_eq!(body["slug"], "my-list"); assert_eq!(body["is_public"], true); assert!(body["id"].as_str().is_some()); } #[tokio::test] async fn create_collection_validates_slug() { let mut h = TestHarness::new().await; h.signup("slugval", "slugval@example.com", "password123").await; // Invalid slug (contains spaces) let resp = h .client .post_json( "/api/collections", r#"{"slug": "bad slug!", "title": "Title"}"#, ) .await; assert!( resp.status.is_client_error(), "Invalid slug should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn create_collection_enforces_limit() { let mut h = TestHarness::new().await; let user_id = h.signup("limituser", "limit@example.com", "password123").await; // Seed 50 collections directly via SQL to hit the limit for i in 0..50 { sqlx::query("INSERT INTO collections (user_id, slug, title) VALUES ($1, $2, $3)") .bind(user_id) .bind(format!("coll-{}", i)) .bind(format!("Collection {}", i)) .execute(&h.db) .await .unwrap(); } let resp = h .client .post_json( "/api/collections", r#"{"slug": "one-too-many", "title": "Overflow"}"#, ) .await; assert!( resp.status.is_client_error(), "Should reject at limit: {} {}", resp.status, resp.text ); assert!( resp.text.contains("50"), "Error should mention limit: {}", resp.text ); } #[tokio::test] async fn update_collection() { let mut h = TestHarness::new().await; h.signup("upduser", "upd@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "orig", "title": "Original", "is_public": false}"#, ) .await; assert_eq!(resp.status, 201); let body: serde_json::Value = resp.json(); let id = body["id"].as_str().unwrap(); let resp = h .client .put_json( &format!("/api/collections/{}", id), r#"{"title": "Updated Title", "description": "Now with desc", "is_public": true}"#, ) .await; assert!(resp.status.is_success(), "Update failed: {} {}", resp.status, resp.text); let body: serde_json::Value = resp.json(); assert_eq!(body["title"], "Updated Title"); assert_eq!(body["is_public"], true); } #[tokio::test] async fn delete_collection() { let mut h = TestHarness::new().await; h.signup("deluser", "del@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "to-delete", "title": "Delete Me"}"#, ) .await; let body: serde_json::Value = resp.json(); let id = body["id"].as_str().unwrap(); let resp = h.client.delete(&format!("/api/collections/{}", id)).await; assert_eq!(resp.status, 204, "Delete failed: {}", resp.text); // Verify it's gone — re-deleting should 404 let resp = h.client.delete(&format!("/api/collections/{}", id)).await; assert_eq!(resp.status, 404); } // ── Add / remove items ── #[tokio::test] async fn add_remove_item() { let mut h = TestHarness::new().await; // Create a creator with a public item let _seller_id = h.create_creator("addrem").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=ar-proj&title=AR+Project") .await .json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Test+Item&price_cents=100&item_type=digital", ) .await .json(); let item_id = item["id"].as_str().unwrap(); h.publish_project_and_item(project_id, item_id).await; // Create a collection let resp = h .client .post_json( "/api/collections", r#"{"slug": "my-favs", "title": "Favourites", "is_public": true}"#, ) .await; assert_eq!(resp.status, 201); let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); // Add item let resp = h .client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_id), "{}", ) .await; assert_eq!(resp.status, 204, "Add item failed: {}", resp.text); // Adding again is idempotent let resp = h .client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_id), "{}", ) .await; assert_eq!(resp.status, 204); // Remove item let resp = h .client .delete(&format!("/api/collections/{}/items/{}", coll_id, item_id)) .await; assert_eq!(resp.status, 204, "Remove item failed: {}", resp.text); } #[tokio::test] async fn add_item_enforces_limit() { let mut h = TestHarness::new().await; let _seller_id = h.create_creator("limitseller").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=lim-proj&title=LimitProject") .await .json(); let project_id = project["id"].as_str().unwrap(); // Create a collection let resp = h .client .post_json( "/api/collections", r#"{"slug": "big-list", "title": "Big List"}"#, ) .await; let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); let coll_uuid: uuid::Uuid = coll_id.parse().unwrap(); // Seed 200 items directly and add them to the collection for i in 0..200 { let item_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO items (project_id, title, price_cents, item_type, is_public, slug) VALUES ($1, $2, 0, 'digital', true, $3) RETURNING id", ) .bind(project_id.parse::().unwrap()) .bind(format!("Item {}", i)) .bind(format!("item-{}", i)) .fetch_one(&h.db) .await .unwrap(); sqlx::query("INSERT INTO collection_items (collection_id, item_id, position) VALUES ($1, $2, $3)") .bind(coll_uuid) .bind(item_id) .bind(i) .execute(&h.db) .await .unwrap(); } // Create one more public item via API let extra: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Extra+Item&price_cents=0&item_type=digital", ) .await .json(); let extra_id = extra["id"].as_str().unwrap(); h.client .put_form(&format!("/api/items/{}", extra_id), "is_public=true") .await; // Should be rejected at 200 let resp = h .client .post_json( &format!("/api/collections/{}/items/{}", coll_id, extra_id), "{}", ) .await; assert!( resp.status.is_client_error(), "Should reject at item limit: {} {}", resp.status, resp.text ); } #[tokio::test] async fn add_nonexistent_item_rejected() { let mut h = TestHarness::new().await; h.signup("noitem", "noitem@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "empty", "title": "Empty"}"#, ) .await; let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); let fake_id = uuid::Uuid::new_v4(); let resp = h .client .post_json( &format!("/api/collections/{}/items/{}", coll_id, fake_id), "{}", ) .await; assert_eq!(resp.status, 404, "Nonexistent item should 404: {}", resp.text); } #[tokio::test] async fn add_draft_item_rejected() { let mut h = TestHarness::new().await; let _seller_id = h.create_creator("draftblock").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=draft-proj&title=DraftProject") .await .json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Draft+Item&price_cents=100&item_type=digital", ) .await .json(); let item_id = item["id"].as_str().unwrap(); // Explicitly unpublish the item (items default to is_public=true) h.client .put_form(&format!("/api/items/{}", item_id), "is_public=false") .await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "draft-coll", "title": "Draft Coll"}"#, ) .await; let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); let resp = h .client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_id), "{}", ) .await; assert!( resp.status.is_client_error(), "Draft item should be rejected: {} {}", resp.status, resp.text ); assert!( resp.text.contains("public"), "Error should mention public: {}", resp.text ); } // ── Public collection page ── #[tokio::test] async fn public_collection_page_visible() { let mut h = TestHarness::new().await; let _creator_id = h.create_creator("pageowner").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=page-proj&title=PageProject") .await .json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Page+Item&price_cents=0&item_type=digital", ) .await .json(); let item_id = item["id"].as_str().unwrap(); h.publish_project_and_item(project_id, item_id).await; // Create public collection and add item let resp = h .client .post_json( "/api/collections", r#"{"slug": "my-picks", "title": "My Picks", "is_public": true}"#, ) .await; let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); h.client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_id), "{}", ) .await; // Log out — anonymous user should see the page h.client.post_form("/logout", "").await; let resp = h.client.get("/c/pageowner/my-picks").await; assert_eq!(resp.status, 200, "Public collection page: {}", resp.text); assert!(resp.text.contains("My Picks"), "Should contain title"); assert!(resp.text.contains("Page Item"), "Should contain item title"); } #[tokio::test] async fn private_collection_page_hidden() { let mut h = TestHarness::new().await; h.signup("privowner", "priv@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "secret", "title": "Secret List", "is_public": false}"#, ) .await; assert_eq!(resp.status, 201); // Log out — anonymous user should get 404 h.client.post_form("/logout", "").await; let resp = h.client.get("/c/privowner/secret").await; assert_eq!( resp.status, 404, "Private collection should be 404 for non-owner: {}", resp.text ); } #[tokio::test] async fn collection_slug_unique_per_user() { let mut h = TestHarness::new().await; // User 1 creates collection with slug "shared-slug" h.signup("user1", "user1@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "shared-slug", "title": "User 1 Collection"}"#, ) .await; assert_eq!(resp.status, 201); // User 2 can create a collection with the same slug h.client.post_form("/logout", "").await; h.signup("user2", "user2@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "shared-slug", "title": "User 2 Collection"}"#, ) .await; assert_eq!( resp.status, 201, "Same slug for different user should work: {} {}", resp.status, resp.text ); // User 2 cannot create a duplicate slug let resp = h .client .post_json( "/api/collections", r#"{"slug": "shared-slug", "title": "Duplicate"}"#, ) .await; assert!( resp.status.is_client_error(), "Duplicate slug for same user should fail: {}", resp.text ); } // ── Ownership checks ── #[tokio::test] async fn owner_only_mutations() { let mut h = TestHarness::new().await; // User A creates a collection h.signup("ownerA", "ownerA@example.com", "password123").await; let resp = h .client .post_json( "/api/collections", r#"{"slug": "owned", "title": "Owned"}"#, ) .await; let body: serde_json::Value = resp.json(); let coll_id = body["id"].as_str().unwrap().to_string(); // User B tries to update h.client.post_form("/logout", "").await; h.signup("ownerB", "ownerB@example.com", "password123").await; let resp = h .client .put_json( &format!("/api/collections/{}", coll_id), r#"{"title": "Hijacked", "is_public": true}"#, ) .await; assert_eq!(resp.status, 403, "Non-owner update should be 403: {}", resp.text); // User B tries to delete let resp = h .client .delete(&format!("/api/collections/{}", coll_id)) .await; assert_eq!(resp.status, 403, "Non-owner delete should be 403: {}", resp.text); } // ── User profile shows public collections ── #[tokio::test] async fn user_profile_shows_public_collections() { let mut h = TestHarness::new().await; h.signup("profuser", "profuser@example.com", "password123").await; // Create a public collection let resp = h .client .post_json( "/api/collections", r#"{"slug": "visible-list", "title": "Visible List", "is_public": true}"#, ) .await; assert_eq!(resp.status, 201); // Create a private collection h.client .post_json( "/api/collections", r#"{"slug": "hidden-list", "title": "Hidden List", "is_public": false}"#, ) .await; // Check profile page let resp = h.client.get("/u/profuser").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("Visible List"), "Public collection should appear on profile" ); assert!( !resp.text.contains("Hidden List"), "Private collection should NOT appear on profile" ); } // ── Reorder items ── #[tokio::test] async fn reorder_items() { let mut h = TestHarness::new().await; let _creator_id = h.create_creator("reorder").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=reord-proj&title=ReorderProject") .await .json(); let project_id = project["id"].as_str().unwrap(); // Create two public items let item_a: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Item+A&price_cents=0&item_type=digital", ) .await .json(); let item_a_id = item_a["id"].as_str().unwrap(); let item_b: serde_json::Value = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Item+B&price_cents=0&item_type=digital", ) .await .json(); let item_b_id = item_b["id"].as_str().unwrap(); // Publish both h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; h.client .put_form(&format!("/api/items/{}", item_a_id), "is_public=true") .await; h.client .put_form(&format!("/api/items/{}", item_b_id), "is_public=true") .await; // Create collection and add both items (A then B) let resp = h .client .post_json( "/api/collections", r#"{"slug": "ordered", "title": "Ordered", "is_public": true}"#, ) .await; let coll: serde_json::Value = resp.json(); let coll_id = coll["id"].as_str().unwrap(); h.client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_a_id), "{}", ) .await; h.client .post_json( &format!("/api/collections/{}/items/{}", coll_id, item_b_id), "{}", ) .await; // Reorder: B before A let reorder_body = format!( r#"{{"item_ids": ["{}","{}"]}}"#, item_b_id, item_a_id ); let resp = h .client .put_json( &format!("/api/collections/{}/items/reorder", coll_id), &reorder_body, ) .await; assert_eq!(resp.status, 204, "Reorder failed: {}", resp.text); // Verify order on the public page — Item B should appear before Item A let resp = h.client.get("/c/reorder/ordered").await; assert_eq!(resp.status, 200); let pos_b = resp.text.find("Item B").expect("Item B not found"); let pos_a = resp.text.find("Item A").expect("Item A not found"); assert!( pos_b < pos_a, "Item B (pos {}) should appear before Item A (pos {})", pos_b, pos_a ); }