//! 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/{project_id}/items"), "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/{item_id}/text"), "{\"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/{item_id}/text"), "{\"body\": \"# Updated\\n\\nRevised article content with more words.\"}", ) .await; assert!( resp.status.is_success(), "Update text failed: {}", resp.text ); 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/{project_id}/items"), "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/{item_id}/text"), 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/{item_id}/text"), 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/{project_id}/items"), "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/{item_id}/duplicate"), "{}") .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/{project_id}/items"), "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/{project_id}/items"), "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" ); }