//! Item management workflow tests — duplication, bulk operations, PWYW, scheduling. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator with a project and N items. Returns (project_id, item_ids). async fn setup_with_items(h: &mut TestHarness, username: &str, n: usize) -> (String, Vec) { let user_id = h.signup(username, &format!("{}@test.com", username), "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login(username, "password123").await; let resp = h .client .post_form("/api/projects", &format!("slug={}-proj&title=Project", username)) .await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let mut item_ids = Vec::new(); for i in 0..n { let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title=Item+{}&price_cents=500&item_type=digital", i + 1), ) .await; assert!(resp.status.is_success(), "Create item {} failed: {}", i + 1, resp.text); let item: Value = resp.json(); item_ids.push(item["id"].as_str().unwrap().to_string()); } (project_id, item_ids) } // --------------------------------------------------------------------------- // Duplication // --------------------------------------------------------------------------- #[tokio::test] async fn duplicate_item_creates_draft_copy() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "dupuser", 1).await; let item_id = &item_ids[0]; // Add a tag to the original let resp = h.client.get("/api/tags/search?q=music").await; let tags: Vec = serde_json::from_str(&resp.text).unwrap(); if !tags.is_empty() { let tag_id = tags[0]["id"].as_str().unwrap(); h.client .post_form( &format!("/api/items/{}/tags", item_id), &format!("tag_id={}", tag_id), ) .await; } // Duplicate let resp = h .client .post_form(&format!("/api/items/{}/duplicate", item_id), "") .await; assert!(resp.status.is_success(), "Duplicate failed: {} {}", resp.status, resp.text); let dup: Value = resp.json(); let dup_title = dup["title"].as_str().unwrap(); assert!( dup_title.starts_with("Copy of"), "Duplicated title should start with 'Copy of': {}", dup_title ); assert_eq!(dup["is_public"].as_bool(), Some(false), "Duplicate should be draft"); assert_ne!(dup["id"].as_str(), Some(item_id.as_str()), "Should have new ID"); } #[tokio::test] async fn duplicate_preserves_price() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "dupprice", 1).await; let resp = h .client .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "") .await; assert!(resp.status.is_success(), "Duplicate failed: {}", resp.text); let dup: Value = resp.json(); assert_eq!(dup["price_cents"].as_i64(), Some(500), "Price should be preserved"); } #[tokio::test] async fn duplicate_non_owner_rejected() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "dupowner", 1).await; // Switch to different user h.client.post_form("/logout", "").await; let other_id = h.signup("dupother", "dupother@test.com", "password123").await; h.grant_creator(other_id).await; h.client.post_form("/logout", "").await; h.login("dupother", "password123").await; let resp = h .client .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "") .await; assert_eq!(resp.status, 403, "Non-owner duplicate should be 403: {}", resp.text); } // --------------------------------------------------------------------------- // Bulk operations // --------------------------------------------------------------------------- #[tokio::test] async fn bulk_publish_items() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "bulkpub", 3).await; let body = item_ids .iter() .map(|id| format!("item_ids={}", id)) .collect::>() .join("&"); let resp = h.client.post_form("/api/items/bulk/publish", &body).await; assert!(resp.status.is_success(), "Bulk publish failed: {} {}", resp.status, resp.text); // Verify all items are now public for item_id in &item_ids { let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(is_public, "Item {} should be public after bulk publish", item_id); } } #[tokio::test] async fn bulk_unpublish_items() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "bulkunpub", 2).await; // First publish them let body = item_ids .iter() .map(|id| format!("item_ids={}", id)) .collect::>() .join("&"); h.client.post_form("/api/items/bulk/publish", &body).await; // Then unpublish let resp = h.client.post_form("/api/items/bulk/unpublish", &body).await; assert!(resp.status.is_success(), "Bulk unpublish failed: {} {}", resp.status, resp.text); for item_id in &item_ids { let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(!is_public, "Item {} should be draft after bulk unpublish", item_id); } } #[tokio::test] async fn bulk_delete_items() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "bulkdel", 3).await; let body = item_ids .iter() .map(|id| format!("item_ids={}", id)) .collect::>() .join("&"); let resp = h.client.post_form("/api/items/bulk/delete", &body).await; assert!(resp.status.is_success(), "Bulk delete failed: {} {}", resp.status, resp.text); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM items WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL", ) .bind( item_ids .iter() .map(|s| s.parse::().unwrap()) .collect::>(), ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "All items should be soft-deleted"); } #[tokio::test] async fn bulk_empty_selection_rejected() { let mut h = TestHarness::new().await; let _ = setup_with_items(&mut h, "bulkempty", 0).await; let resp = h.client.post_form("/api/items/bulk/publish", "").await; assert!( resp.status.is_client_error(), "Empty bulk publish should fail: {} {}", resp.status, resp.text ); } #[tokio::test] async fn bulk_cross_user_rejected() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "bulkauth", 1).await; // Switch to different user h.client.post_form("/logout", "").await; let other = h.signup("bulkother", "bulkother@test.com", "password123").await; h.grant_creator(other).await; h.client.post_form("/logout", "").await; h.login("bulkother", "password123").await; let body = format!("item_ids={}", item_ids[0]); let resp = h.client.post_form("/api/items/bulk/publish", &body).await; assert_eq!(resp.status, 403, "Cross-user bulk should be 403: {}", resp.text); } // --------------------------------------------------------------------------- // PWYW // --------------------------------------------------------------------------- #[tokio::test] async fn pwyw_enable_and_set_minimum() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "pwywenable", 1).await; let item_id = &item_ids[0]; // Enable PWYW with minimum $5 let resp = h .client .put_form( &format!("/api/items/{}", item_id), "pwyw_enabled=on&pwyw_min_cents=500", ) .await; assert!(resp.status.is_success(), "Enable PWYW failed: {} {}", resp.status, resp.text); // Verify in DB let (enabled, min): (bool, i32) = sqlx::query_as( "SELECT pwyw_enabled, pwyw_min_cents FROM items WHERE id = $1::uuid", ) .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(enabled, "PWYW should be enabled"); assert_eq!(min, 500, "PWYW min should be $5"); } #[tokio::test] async fn pwyw_disable() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "pwywnope", 1).await; let item_id = &item_ids[0]; // Enable then disable h.client .put_form(&format!("/api/items/{}", item_id), "pwyw_enabled=on&pwyw_min_cents=100") .await; let resp = h .client .put_form(&format!("/api/items/{}", item_id), "pwyw_enabled=off") .await; assert!(resp.status.is_success(), "Disable PWYW failed: {} {}", resp.status, resp.text); let enabled: bool = sqlx::query_scalar("SELECT pwyw_enabled FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(!enabled, "PWYW should be disabled"); } // --------------------------------------------------------------------------- // Scheduled publishing // --------------------------------------------------------------------------- #[tokio::test] async fn scheduled_publish_keeps_item_draft() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "sched", 1).await; let item_id = &item_ids[0]; // Set publish_at to future date — item should stay draft let resp = h .client .put_form( &format!("/api/items/{}", item_id), "publish_at=2030-01-01T00:00:00Z&is_public=true", ) .await; assert!(resp.status.is_success(), "Schedule publish failed: {} {}", resp.status, resp.text); let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(!is_public, "Item should remain draft when scheduled for future"); let publish_at: Option> = sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(publish_at.is_some(), "publish_at should be set"); } #[tokio::test] async fn clear_scheduled_publish() { let mut h = TestHarness::new().await; let (_, item_ids) = setup_with_items(&mut h, "unsched", 1).await; let item_id = &item_ids[0]; // Schedule then clear h.client .put_form( &format!("/api/items/{}", item_id), "publish_at=2030-01-01T00:00:00Z", ) .await; let resp = h .client .put_form(&format!("/api/items/{}", item_id), "publish_at=") .await; assert!(resp.status.is_success(), "Clear schedule failed: {} {}", resp.status, resp.text); let publish_at: Option> = sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid") .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert!(publish_at.is_none(), "publish_at should be cleared"); } // --------------------------------------------------------------------------- // Text content // --------------------------------------------------------------------------- #[tokio::test] async fn text_content_word_count() { let mut h = TestHarness::new().await; let user_id = h.signup("textcount", "textcount@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("textcount", "password123").await; let resp = h .client .post_form("/api/projects", "slug=text-proj&title=Text+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=My+Essay&item_type=text", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Set text content with known word count let body = "one two three four five six seven eight nine ten"; let resp = h .client .put_json( &format!("/api/items/{}/text", item_id), &format!(r#"{{"body": "{}"}}"#, body), ) .await; assert!(resp.status.is_success(), "Set text failed: {}", resp.text); let data: Value = resp.json(); assert_eq!(data["word_count"].as_u64(), Some(10), "Should count 10 words"); }