//! Versions: create, is_current toggling, ordering, ownership, validation, draft visibility. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator with a project and a digital item, return (project_id, item_id). async fn setup_creator_with_digital_item( h: &mut TestHarness, username: &str, _email: &str, ) -> (String, String) { let setup = h.create_creator_with_item(username, "digital", 0).await; (setup.project_id, setup.item_id) } #[tokio::test] async fn version_create_and_is_current() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vcreator", "vcreator@test.com").await; // Create v1 let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "1.0.0", "changelog": "Initial release"}"#, ) .await; assert!(resp.status.is_success(), "Create v1 failed: {} {}", resp.status, resp.text); let v1: Value = resp.json(); let v1_id = v1["id"].as_str().unwrap().to_string(); assert!(v1["is_current"].as_bool().unwrap(), "v1 should be current on creation"); // Create v2 let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "2.0.0", "changelog": "Major update"}"#, ) .await; assert!(resp.status.is_success(), "Create v2 failed: {} {}", resp.status, resp.text); let v2: Value = resp.json(); assert!(v2["is_current"].as_bool().unwrap(), "v2 should be current"); // Verify v1 is no longer current let v1_current: bool = sqlx::query_scalar("SELECT is_current FROM versions WHERE id = $1") .bind(v1_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert!(!v1_current, "v1 should no longer be current after v2 was created"); } #[tokio::test] async fn version_list_newest_first() { let mut h = TestHarness::new().await; let (project_id, item_id) = setup_creator_with_digital_item(&mut h, "vlist", "vlist@test.com").await; // Create 3 versions for ver in ["1.0.0", "1.1.0", "2.0.0"] { let body = format!(r#"{{"version_number": "{ver}"}}"#); let resp = h .client .post_json(&format!("/api/items/{}/versions", item_id), &body) .await; assert!(resp.status.is_success(), "Create version {ver} 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 versions — newest first (ORDER BY created_at DESC) let resp = h .client .get(&format!("/api/items/{}/versions", item_id)) .await; assert!(resp.status.is_success(), "List versions 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]["version_number"].as_str().unwrap(), "2.0.0"); assert_eq!(data[1]["version_number"].as_str().unwrap(), "1.1.0"); assert_eq!(data[2]["version_number"].as_str().unwrap(), "1.0.0"); } #[tokio::test] async fn version_ownership_enforced() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vowner", "vowner@test.com").await; // Switch to creator B h.client.post_form("/logout", "").await; let b_id = h.signup("vintruder", "vintruder@test.com", "password123").await; h.grant_creator(b_id).await; h.client.post_form("/logout", "").await; h.login("vintruder", "password123").await; // Creator B tries to create a version on A's item let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "9.9.9"}"#, ) .await; assert_eq!( resp.status, 403, "Non-owner should get 403 on POST version, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn version_validation() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vvalid", "vvalid@test.com").await; // Empty version_number let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": ""}"#, ) .await; assert!( resp.status == 400 || resp.status == 422, "Empty version_number should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn list_versions_requires_public_item() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vdraft", "vdraft@test.com").await; // Create a version, then make item non-public let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "0.1.0"}"#, ) .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 versions of draft item h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .get(&format!("/api/items/{}/versions", item_id)) .await; assert_eq!( resp.status, 404, "Draft item versions should return 404, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn version_changelog_too_long() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vchangelog", "vchangelog@test.com").await; // changelog > 10,000 chars let long_changelog = "x".repeat(10_001); let body = serde_json::json!({ "version_number": "1.0.0", "changelog": long_changelog, }); let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), &body.to_string(), ) .await; assert!( resp.status == 400 || resp.status == 422, "Changelog >10000 chars should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn version_number_too_long() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vlong", "vlong@test.com").await; // version_number > 50 chars let long_ver = "v".repeat(51); let body = serde_json::json!({ "version_number": long_ver, }); let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), &body.to_string(), ) .await; assert!( resp.status == 400 || resp.status == 422, "Version number >50 chars should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn version_number_boundary_succeeds() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vbound", "vbound@test.com").await; // Exactly 50 chars — should succeed let ver_50 = "v".repeat(50); let body = serde_json::json!({ "version_number": ver_50, }); let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), &body.to_string(), ) .await; assert!( resp.status.is_success(), "50-char version number should be accepted, got {} {}", resp.status, resp.text ); let version: Value = resp.json(); assert_eq!(version["version_number"].as_str().unwrap(), ver_50); } #[tokio::test] async fn version_optional_fields_preserved() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vfields", "vfields@test.com").await; let body = serde_json::json!({ "version_number": "3.0.0", "changelog": "Added widgets", "file_url": "https://example.com/app-3.0.0.zip", "file_size_bytes": 1048576_i64, "file_name": "app-3.0.0.zip", }); let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), &body.to_string(), ) .await; assert!( resp.status.is_success(), "Create version with optional fields failed: {} {}", resp.status, resp.text ); let version: Value = resp.json(); let version_id = version["id"].as_str().unwrap(); // Verify fields in response assert_eq!(version["changelog"].as_str().unwrap(), "Added widgets"); assert_eq!( version["file_url"].as_str().unwrap(), "https://example.com/app-3.0.0.zip" ); // Verify file_size_bytes and file_name via direct SQL (not in VersionResponse) let (file_size, file_name): (Option, Option) = sqlx::query_as( "SELECT file_size_bytes, file_name FROM versions WHERE id = $1", ) .bind(version_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(file_size, Some(1048576)); assert_eq!(file_name.as_deref(), Some("app-3.0.0.zip")); } #[tokio::test] async fn version_unauthenticated_rejected() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_digital_item(&mut h, "vunauth", "vunauth@test.com").await; // Logout h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .post_json( &format!("/api/items/{}/versions", item_id), r#"{"version_number": "1.0.0"}"#, ) .await; assert_eq!( resp.status, 401, "Unauthenticated POST version should be 401, got {} {}", resp.status, resp.text ); }