//! Content lifecycle: create item -> add text content -> update -> delete -> soft-deleted use crate::harness::TestHarness; use serde_json::Value; #[tokio::test] async fn item_lifecycle() { let mut h = TestHarness::new().await; // Setup: creator with project let user_id = h.signup("author", "author@example.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("author", "password123").await; let resp = h .client .post_form("/api/projects", "slug=my-project&title=My+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); // Create item let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=My+Article&item_type=text", ) .await; assert!(resp.status.is_success(), "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Add text content let resp = h .client .put_json( &format!("/api/items/{}/text", item_id), "{\"body\": \"# Hello\\n\\nThis is my article content.\"}", ) .await; assert!( resp.status.is_success(), "Update text failed: {} {}", resp.status, resp.text ); let text_resp: Value = resp.json(); assert!(text_resp["word_count"].as_u64().unwrap() > 0); // Update text content let resp = h .client .put_json( &format!("/api/items/{}/text", item_id), "{\"body\": \"# Updated\\n\\nRevised article content with more words.\"}", ) .await; assert!(resp.status.is_success(), "Update text failed: {}", resp.text); // Delete item let resp = h .client .delete(&format!("/api/items/{}", item_id)) .await; assert!(resp.status.is_success(), "Delete item failed: {}", resp.text); // Verify item is soft-deleted (deleted_at set, not visible to normal queries) let deleted_at: Option> = sqlx::query_scalar( "SELECT deleted_at FROM items WHERE id = $1", ) .bind(item_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert!(deleted_at.is_some(), "Item should be soft-deleted (deleted_at set)"); // Verify item is not visible to normal listing queries let visible_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM items WHERE id = $1 AND deleted_at IS NULL", ) .bind(item_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(visible_count, 0, "Soft-deleted item should not be visible"); } #[tokio::test] async fn item_text_update() { let mut h = TestHarness::new().await; let user_id = h.signup("textwriter", "textwriter@example.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("textwriter", "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(); // Create a text item let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Text+Article&item_type=text", ) .await; assert!(resp.status.is_success(), "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Set text body let resp = h .client .put_json( &format!("/api/items/{}/text", item_id), r##"{"body": "# First Draft\n\nSome initial content here."}"##, ) .await; assert!(resp.status.is_success(), "Set text failed: {} {}", resp.status, resp.text); let text: Value = resp.json(); assert!(text["word_count"].as_i64().unwrap() > 0, "Word count should be positive"); // Update text body let resp = h .client .put_json( &format!("/api/items/{}/text", item_id), r##"{"body": "# Revised Draft\n\nCompletely rewritten with new material and extra words."}"##, ) .await; assert!(resp.status.is_success(), "Update text failed: {} {}", resp.status, resp.text); let text: Value = resp.json(); assert_eq!( text["body"].as_str(), Some("# Revised Draft\n\nCompletely rewritten with new material and extra words."), "Body should reflect the update" ); } #[tokio::test] async fn item_duplicate() { let mut h = TestHarness::new().await; let user_id = h.signup("dupuser", "dupuser@example.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("dupuser", "password123").await; let resp = h .client .post_form("/api/projects", "slug=dup-proj&title=Dup+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); // Create item with title, description, price let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Original+Item&item_type=text&price_cents=500", ) .await; assert!(resp.status.is_success(), "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Add a description let resp = h .client .put_form( &format!("/api/items/{}", item_id), "description=A+great+item", ) .await; assert!(resp.status.is_success(), "Update item failed: {}", resp.text); // Duplicate let resp = h .client .post_json( &format!("/api/items/{}/duplicate", item_id), "{}", ) .await; assert!(resp.status.is_success(), "Duplicate failed: {} {}", resp.status, resp.text); let dup: Value = resp.json(); // Verify the duplicate has "Copy of" prefix and is a draft let dup_title = dup["title"].as_str().unwrap(); assert!( dup_title.starts_with("Copy of"), "Duplicate title should start with 'Copy of', got: {}", dup_title ); assert_eq!(dup["is_public"].as_bool(), Some(false), "Duplicate should be a draft"); assert_eq!(dup["price_cents"].as_i64(), Some(500), "Duplicate should preserve price"); assert_ne!(dup["id"].as_str(), Some(item_id), "Duplicate should have a new ID"); } #[tokio::test] async fn non_owner_cannot_edit_item() { let mut h = TestHarness::new().await; // User A creates project + item let user_a = h.signup("itemowner", "itemowner@example.com", "password123").await; h.grant_creator(user_a).await; h.client.post_form("/logout", "").await; h.login("itemowner", "password123").await; let resp = h .client .post_form("/api/projects", "slug=owner-proj&title=Owner+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=Private+Item&item_type=text", ) .await; assert!(resp.status.is_success(), "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Log out, sign up user B h.client.post_form("/logout", "").await; let user_b = h.signup("itemintruder", "itemintruder@example.com", "password123").await; h.grant_creator(user_b).await; h.client.post_form("/logout", "").await; h.login("itemintruder", "password123").await; // User B tries to update user A's item let resp = h .client .put_form( &format!("/api/items/{}", item_id), "title=Hacked+Item", ) .await; assert_eq!(resp.status, 403, "Non-owner update should be 403, got {}", resp.status); } #[tokio::test] async fn publish_unpublish_item() { let mut h = TestHarness::new().await; let user_id = h.signup("pubuser", "pubuser@example.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("pubuser", "password123").await; let resp = h .client .post_form("/api/projects", "slug=pub-proj&title=Pub+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); // Create item (public by default per DB schema) let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Toggle+Item&item_type=text", ) .await; assert!(resp.status.is_success(), "Create item failed: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); assert_eq!(item["is_public"].as_bool(), Some(true), "New item should be public by default"); // Unpublish (make draft) let resp = h .client .put_form( &format!("/api/items/{}", item_id), "is_public=false", ) .await; assert!(resp.status.is_success(), "Unpublish failed: {} {}", resp.status, resp.text); let updated: Value = resp.json(); assert_eq!(updated["is_public"].as_bool(), Some(false), "Item should be draft after unpublish"); // Republish let resp = h .client .put_form( &format!("/api/items/{}", item_id), "is_public=true", ) .await; assert!(resp.status.is_success(), "Publish failed: {} {}", resp.status, resp.text); let updated: Value = resp.json(); assert_eq!(updated["is_public"].as_bool(), Some(true), "Item should be public after republish"); }