//! 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!("{username}@test.com"), "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={username}-proj&title=Project"), ) .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/{project_id}/items"), &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/{item_id}/tags"), &format!("tag_id={tag_id}"), ) .await; } // Duplicate let resp = h .client .post_form(&format!("/api/items/{item_id}/duplicate"), "") .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 {item_id} should be public after bulk publish" ); } } #[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; 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 {item_id} should be draft after bulk unpublish" ); } } #[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/{project_id}/items"), "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/{item_id}/text"), &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" ); } // create_item dedups slugs via the insert-retry on the per-project unique index // (the redundant pre-insert SELECT was removed). Two items with the same title in // one project must still get distinct slugs (base, base-2). #[tokio::test] async fn items_with_same_title_get_distinct_slugs() { let mut h = TestHarness::new().await; let user_id = h .signup("slugdedup", "slugdedup@test.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("slugdedup", "password123").await; let resp = h .client .post_form("/api/projects", "slug=slugdedup-proj&title=Project") .await; assert!(resp.status.is_success(), "create project: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); for _ in 0..2 { let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Same+Title&price_cents=0&item_type=digital", ) .await; assert!(resp.status.is_success(), "create item: {}", resp.text); } let mut slugs: Vec = sqlx::query_scalar("SELECT slug FROM items WHERE project_id = $1::uuid") .bind(&project_id) .fetch_all(&h.db) .await .unwrap(); slugs.sort(); assert_eq!( slugs, vec!["same-title".to_string(), "same-title-2".to_string()], "same-title items must get distinct slugs via the insert-retry dedup" ); }