//! Project management workflow tests — CRUD, update, delete cascade. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator and return user_id. async fn setup_creator(h: &mut TestHarness, username: &str) -> String { h.create_creator(username).await.to_string() } #[tokio::test] async fn create_project_returns_slug() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projcreate").await; let resp = h .client .post_form("/api/projects", "slug=my-cool-project&title=Cool+Project") .await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); assert_eq!(project["slug"].as_str().unwrap(), "my-cool-project"); assert_eq!(project["title"].as_str().unwrap(), "Cool Project"); } #[tokio::test] async fn create_project_requires_creator() { let mut h = TestHarness::new().await; let _user_id = h.signup("projnoauth", "projnoauth@test.com", "password123").await; let resp = h .client .post_form("/api/projects", "slug=blocked&title=Blocked") .await; assert!( resp.status.is_client_error(), "Non-creator should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn update_project_title_and_description() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projupdate").await; let resp = h .client .post_form("/api/projects", "slug=updatable&title=Original") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .put_json( &format!("/api/projects/{}", project_id), r#"{"title": "Updated Title", "description": "A new description"}"#, ) .await; assert!(resp.status.is_success(), "Update project failed: {} {}", resp.status, resp.text); // Verify in DB let (title, desc): (String, Option) = sqlx::query_as( "SELECT title, description FROM projects WHERE id = $1::uuid", ) .bind(project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(title, "Updated Title"); assert_eq!(desc.as_deref(), Some("A new description")); } #[tokio::test] async fn update_project_non_owner_rejected() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projown").await; let resp = h .client .post_form("/api/projects", "slug=owned-proj&title=Owned") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Switch to different creator h.client.post_form("/logout", "").await; setup_creator(&mut h, "projintruder").await; let resp = h .client .put_json( &format!("/api/projects/{}", project_id), r#"{"title": "Hacked"}"#, ) .await; assert_eq!(resp.status, 403, "Non-owner update should be 403: {}", resp.text); } #[tokio::test] async fn delete_project_cascades_to_items() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projdel").await; let resp = h .client .post_form("/api/projects", "slug=deleteme&title=Delete+Me") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Add items for i in 0..3 { let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title=Item+{}", i), ) .await; assert!(resp.status.is_success()); } // Delete project let resp = h .client .delete(&format!("/api/projects/{}", project_id)) .await; assert!(resp.status.is_success(), "Delete project failed: {} {}", resp.status, resp.text); // Verify project and items are gone let proj_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(proj_count, 0, "Project should be deleted"); let item_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items WHERE project_id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(item_count, 0, "Items should be cascade-deleted"); } #[tokio::test] async fn delete_project_non_owner_rejected() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projdelown").await; let resp = h .client .post_form("/api/projects", "slug=nodelete&title=No+Delete") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Switch user h.client.post_form("/logout", "").await; setup_creator(&mut h, "projdelother").await; let resp = h.client.delete(&format!("/api/projects/{}", project_id)).await; assert_eq!(resp.status, 403, "Non-owner delete should be 403: {}", resp.text); } #[tokio::test] async fn duplicate_slug_rejected() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projslug").await; let resp = h .client .post_form("/api/projects", "slug=unique-slug&title=First") .await; assert!(resp.status.is_success()); // Same slug should fail (may return 4xx or 5xx depending on error handling) let resp = h .client .post_form("/api/projects", "slug=unique-slug&title=Second") .await; assert!( !resp.status.is_success(), "Duplicate slug should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn project_with_category() { let mut h = TestHarness::new().await; setup_creator(&mut h, "projcat").await; let resp = h .client .post_form( "/api/projects", "slug=music-proj&title=Music+Project&category=Music", ) .await; assert!(resp.status.is_success(), "Create project with category failed: {}", resp.text); // Verify category was set let category: Option = sqlx::query_scalar("SELECT c.name FROM projects p JOIN project_categories c ON p.category_id = c.id WHERE p.slug = 'music-proj'") .fetch_optional(&h.db) .await .unwrap(); assert_eq!(category.as_deref(), Some("Music"), "Category should be set"); }