//! Chapters: CRUD lifecycle, ordering, ownership, validation, draft visibility. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator with a project and an audio item, return (project_id, item_id). async fn setup_creator_with_audio_item( h: &mut TestHarness, username: &str, _email: &str, ) -> (String, String) { let setup = h.create_creator_with_item(username, "audio", 0).await; (setup.project_id, setup.item_id) } #[tokio::test] async fn chapter_create_update_delete() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chcreator", "chcreator@test.com").await; // Create chapter let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Intro", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success(), "Create chapter failed: {} {}", resp.status, resp.text); let chapter: Value = resp.json(); let chapter_id = chapter["id"].as_str().unwrap().to_string(); assert_eq!(chapter["title"].as_str().unwrap(), "Intro"); // Update chapter let resp = h .client .put_json( &format!("/api/chapters/{}", chapter_id), r#"{"title": "Introduction", "start_seconds": 5.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success(), "Update chapter failed: {} {}", resp.status, resp.text); let updated: Value = resp.json(); assert_eq!(updated["title"].as_str().unwrap(), "Introduction"); // Delete chapter let resp = h .client .delete(&format!("/api/chapters/{}", chapter_id)) .await; assert!(resp.status.is_success(), "Delete chapter failed: {} {}", resp.status, resp.text); // Verify gone from DB let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM chapters WHERE id = $1") .bind(chapter_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Chapter should be deleted from database"); } #[tokio::test] async fn chapter_ordering() { let mut h = TestHarness::new().await; let (project_id, item_id) = setup_creator_with_audio_item(&mut h, "chorder", "chorder@test.com").await; // Create 3 chapters out of order for (title, sort, secs) in [("Third", 3, 60.0f32), ("First", 1, 0.0), ("Second", 2, 30.0)] { let body = format!( r#"{{"title": "{title}", "start_seconds": {secs}, "sort_order": {sort}}}"#, ); let resp = h .client .post_json(&format!("/api/items/{}/chapters", item_id), &body) .await; assert!(resp.status.is_success(), "Create chapter '{title}' failed: {} {}", resp.status, resp.text); } // Make item public so list endpoint works h.client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; // List chapters — should be sorted by sort_order let resp = h .client .get(&format!("/api/items/{}/chapters", item_id)) .await; assert!(resp.status.is_success(), "List chapters failed: {} {}", resp.status, resp.text); let list: Value = resp.json(); let data = list["data"].as_array().unwrap(); assert_eq!(data.len(), 3); assert_eq!(data[0]["title"].as_str().unwrap(), "First"); assert_eq!(data[1]["title"].as_str().unwrap(), "Second"); assert_eq!(data[2]["title"].as_str().unwrap(), "Third"); } #[tokio::test] async fn chapter_ownership_enforced() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chowner", "chowner@test.com").await; // Creator A creates a chapter let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Owner Chapter", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success()); let chapter: Value = resp.json(); let chapter_id = chapter["id"].as_str().unwrap().to_string(); // Switch to creator B h.client.post_form("/logout", "").await; let b_id = h.signup("chintruder", "chintruder@test.com", "password123").await; h.grant_creator(b_id).await; h.client.post_form("/logout", "").await; h.login("chintruder", "password123").await; // Creator B tries to update A's chapter let resp = h .client .put_json( &format!("/api/chapters/{}", chapter_id), r#"{"title": "Hacked", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert_eq!( resp.status, 403, "Non-owner should get 403 on PUT, got {} {}", resp.status, resp.text ); // Creator B tries to delete A's chapter let resp = h .client .delete(&format!("/api/chapters/{}", chapter_id)) .await; assert_eq!( resp.status, 403, "Non-owner should get 403 on DELETE, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn chapter_title_validation() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chvalid", "chvalid@test.com").await; // Empty title let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert!( resp.status == 400 || resp.status == 422, "Empty title should be rejected, got {} {}", resp.status, resp.text ); // 201-char title (exceeds 200 limit) let long_title = "A".repeat(201); let body = format!( r#"{{"title": "{long_title}", "start_seconds": 0.0, "sort_order": 1}}"#, ); let resp = h .client .post_json(&format!("/api/items/{}/chapters", item_id), &body) .await; assert!( resp.status == 400 || resp.status == 422, "201-char title should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn list_chapters_requires_public_item() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chdraft", "chdraft@test.com").await; // Add a chapter, then make item non-public let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Draft Chapter", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success()); // Mark item as draft (not public) h.client .put_form(&format!("/api/items/{}", item_id), "is_public=false") .await; // Logout — unauthenticated user tries to list chapters of draft item h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .get(&format!("/api/items/{}/chapters", item_id)) .await; assert_eq!( resp.status, 404, "Draft item chapters should return 404, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn chapter_create_on_non_owned_item() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chownpost", "chownpost@test.com").await; // Switch to creator B h.client.post_form("/logout", "").await; let b_id = h.signup("chownpostb", "chownpostb@test.com", "password123").await; h.grant_creator(b_id).await; h.client.post_form("/logout", "").await; h.login("chownpostb", "password123").await; // Creator B tries to POST a chapter on A's item let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Hacked", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert_eq!( resp.status, 403, "Non-owner should get 403 on POST chapter, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn chapter_unauthenticated_rejected() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chunauth", "chunauth@test.com").await; // Create a chapter so we have an ID for PUT/DELETE let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "Temp", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert!(resp.status.is_success()); let chapter: Value = resp.json(); let chapter_id = chapter["id"].as_str().unwrap().to_string(); // Logout h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; // POST chapter — should be 401 let resp = h .client .post_json( &format!("/api/items/{}/chapters", item_id), r#"{"title": "No Auth", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert_eq!( resp.status, 401, "Unauthenticated POST chapter should be 401, got {} {}", resp.status, resp.text ); // PUT chapter — should be 401 let resp = h .client .put_json( &format!("/api/chapters/{}", chapter_id), r#"{"title": "No Auth", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert_eq!( resp.status, 401, "Unauthenticated PUT chapter should be 401, got {} {}", resp.status, resp.text ); // DELETE chapter — should be 401 let resp = h .client .delete(&format!("/api/chapters/{}", chapter_id)) .await; assert_eq!( resp.status, 401, "Unauthenticated DELETE chapter should be 401, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn chapter_title_boundary_succeeds() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_audio_item(&mut h, "chbound", "chbound@test.com").await; // Exactly 200 characters — should succeed let title_200 = "A".repeat(200); let body = format!( r#"{{"title": "{title_200}", "start_seconds": 0.0, "sort_order": 1}}"#, ); let resp = h .client .post_json(&format!("/api/items/{}/chapters", item_id), &body) .await; assert!( resp.status.is_success(), "200-char title should be accepted, got {} {}", resp.status, resp.text ); let chapter: Value = resp.json(); assert_eq!(chapter["title"].as_str().unwrap(), title_200); } #[tokio::test] async fn chapter_create_nonexistent_item() { let mut h = TestHarness::new().await; // Need a creator user to pass auth let user_id = h.signup("chghost", "chghost@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("chghost", "password123").await; let fake_id = uuid::Uuid::new_v4(); let resp = h .client .post_json( &format!("/api/items/{}/chapters", fake_id), r#"{"title": "Ghost", "start_seconds": 0.0, "sort_order": 1}"#, ) .await; assert_eq!( resp.status, 404, "Chapter on nonexistent item should be 404, got {} {}", resp.status, resp.text ); }