//! Project sections: CRUD lifecycle, reorder, max limit, ownership, validation, public visibility. //! Mirrors item_sections tests but scoped to project-level markdown pages. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator with a project (no item needed), return project_id. async fn setup_creator_with_project(h: &mut TestHarness, username: &str) -> String { let setup = h.create_creator_with_item(username, "plugin", 0).await; setup.project_id } #[tokio::test] async fn project_section_create_update_delete() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecrud").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Privacy Policy", "body": "We don't collect data."}"#, ) .await; assert!(resp.status.is_success(), "Create failed: {} {}", resp.status, resp.text); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); assert_eq!(section["title"].as_str().unwrap(), "Privacy Policy"); assert_eq!(section["slug"].as_str().unwrap(), "privacy-policy"); assert_eq!(section["sort_order"].as_i64().unwrap(), 0); assert_eq!(section["project_id"].as_str().unwrap(), project_id); let resp = h .client .put_json( &format!("/api/project-sections/{}", section_id), r#"{"title": "Privacy & Terms", "body": "We still don't collect data."}"#, ) .await; assert!(resp.status.is_success(), "Update failed: {} {}", resp.status, resp.text); let updated: Value = resp.json(); assert_eq!(updated["title"].as_str().unwrap(), "Privacy & Terms"); assert_eq!(updated["slug"].as_str().unwrap(), "privacy-terms"); let resp = h .client .delete(&format!("/api/project-sections/{}", section_id)) .await; assert!(resp.status.is_success(), "Delete failed: {} {}", resp.status, resp.text); let count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM project_sections WHERE id = $1", ) .bind(section_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Section should be deleted from database"); } #[tokio::test] async fn project_section_list_public_only() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "pseclist").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "FAQ", "body": "Q: ...?"}"#, ) .await; assert!(resp.status.is_success()); // Make project private (projects default to is_public=true). h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": false}"#).await; h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await; assert_eq!(resp.status, 404, "Private project sections should return 404"); // Publish project h.login("pseclist", "password123").await; h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await; h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await; assert!(resp.status.is_success(), "Public list failed: {} {}", resp.status, resp.text); let list: Value = resp.json(); let data = list["data"].as_array().unwrap(); assert_eq!(data.len(), 1); assert_eq!(data[0]["title"].as_str().unwrap(), "FAQ"); } #[tokio::test] async fn project_section_reorder() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecreorder").await; let mut ids = Vec::new(); for title in &["Alpha", "Beta", "Gamma"] { let body = format!(r#"{{"title": "{}", "body": ""}}"#, title); let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; assert!(resp.status.is_success()); let sec: Value = resp.json(); ids.push(sec["id"].as_str().unwrap().to_string()); } let reorder_body = format!( r#"{{"section_ids": ["{}", "{}", "{}"]}}"#, ids[2], ids[0], ids[1] ); let resp = h .client .put_json( &format!("/api/projects/{}/sections/reorder", project_id), &reorder_body, ) .await; assert!(resp.status.is_success(), "Reorder failed: {} {}", resp.status, resp.text); let rows = sqlx::query_as::<_, (String, i32)>( "SELECT title, sort_order FROM project_sections WHERE project_id = $1 ORDER BY sort_order", ) .bind(project_id.parse::().unwrap()) .fetch_all(&h.db) .await .unwrap(); assert_eq!(rows[0].0, "Gamma"); assert_eq!(rows[1].0, "Alpha"); assert_eq!(rows[2].0, "Beta"); } #[tokio::test] async fn project_section_max_limit() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecmax").await; for i in 0..10 { let body = format!(r#"{{"title": "Page {}", "body": ""}}"#, i); let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; assert!( resp.status.is_success(), "Page {} create failed: {} {}", i, resp.status, resp.text ); } let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Too Many", "body": ""}"#, ) .await; assert!( resp.status == 400 || resp.status == 422, "11th section should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn project_section_ownership_enforced() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecowner").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Private", "body": "secret"}"#, ) .await; assert!(resp.status.is_success()); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); h.client.post_form("/logout", "").await; let b_id = h.signup("psecintruder", "psecintruder@test.com", "password123").await; h.grant_creator(b_id).await; h.client.post_form("/logout", "").await; h.login("psecintruder", "password123").await; let resp = h .client .put_json( &format!("/api/project-sections/{}", section_id), r#"{"title": "Hacked", "body": "pwned"}"#, ) .await; assert_eq!(resp.status, 403, "Non-owner PUT should be 403, got {} {}", resp.status, resp.text); let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await; assert_eq!(resp.status, 403, "Non-owner DELETE should be 403, got {} {}", resp.status, resp.text); let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Inject", "body": ""}"#, ) .await; assert_eq!(resp.status, 403, "Non-owner POST should be 403, got {} {}", resp.status, resp.text); } #[tokio::test] async fn project_section_title_validation() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecvalid").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "", "body": ""}"#, ) .await; assert!( resp.status == 400 || resp.status == 422, "Empty title rejected, got {} {}", resp.status, resp.text ); let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": " ", "body": ""}"#, ) .await; assert!( resp.status == 400 || resp.status == 422, "Whitespace title rejected, got {} {}", resp.status, resp.text ); let long_title = "A".repeat(101); let body = format!(r#"{{"title": "{}", "body": ""}}"#, long_title); let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; assert!( resp.status == 400 || resp.status == 422, "101-char title rejected, got {} {}", resp.status, resp.text ); let title_100 = "A".repeat(100); let body = format!(r#"{{"title": "{}", "body": ""}}"#, title_100); let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; assert!( resp.status.is_success(), "100-char title accepted, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn project_section_unauthenticated_rejected() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecunauth").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Temp", "body": ""}"#, ) .await; assert!(resp.status.is_success()); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "No Auth", "body": ""}"#, ) .await; assert_eq!(resp.status, 401, "Unauth POST: 401, got {} {}", resp.status, resp.text); let resp = h .client .put_json( &format!("/api/project-sections/{}", section_id), r#"{"title": "No Auth", "body": ""}"#, ) .await; assert_eq!(resp.status, 401, "Unauth PUT: 401, got {} {}", resp.status, resp.text); let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await; assert_eq!(resp.status, 401, "Unauth DELETE: 401, got {} {}", resp.status, resp.text); } #[tokio::test] async fn project_section_nonexistent_project() { let mut h = TestHarness::new().await; let user_id = h.signup("psecghost", "psecghost@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("psecghost", "password123").await; let fake_id = uuid::Uuid::new_v4(); let resp = h .client .post_json( &format!("/api/projects/{}/sections", fake_id), r#"{"title": "Ghost", "body": ""}"#, ) .await; assert_eq!(resp.status, 404, "Nonexistent project: 404, got {} {}", resp.status, resp.text); } #[tokio::test] async fn project_section_slug_generation() { let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecslug").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Terms of Service!", "body": ""}"#, ) .await; assert!(resp.status.is_success()); let section: Value = resp.json(); let slug = section["slug"].as_str().unwrap(); assert!(slug.contains("terms"), "Slug should contain 'terms', got '{}'", slug); assert!(!slug.contains('!'), "Slug should not contain '!', got '{}'", slug); } #[tokio::test] async fn project_section_unique_slug_per_project() { // Two different projects can have sections with the same slug; // within one project, duplicates fail at the DB unique index. let mut h = TestHarness::new().await; let project_id = setup_creator_with_project(&mut h, "psecuniq").await; let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Privacy Policy", "body": ""}"#, ) .await; assert!(resp.status.is_success()); let resp = h .client .post_json( &format!("/api/projects/{}/sections", project_id), r#"{"title": "Privacy Policy", "body": ""}"#, ) .await; assert!( !resp.status.is_success(), "Duplicate slug within a project should fail, got {} {}", resp.status, resp.text ); }