diff --git a/dongle-smartcontract/src/featured_registry.rs b/dongle-smartcontract/src/featured_registry.rs new file mode 100644 index 0000000..a6035ee --- /dev/null +++ b/dongle-smartcontract/src/featured_registry.rs @@ -0,0 +1,91 @@ +//! Featured projects registry – admin-only curation of highlighted projects. + +use crate::auth::require_admin_auth; +use crate::errors::ContractError; +use crate::events::publish_featured_project_event; +use crate::storage_keys::StorageKey; +use crate::types::Project; +use soroban_sdk::{Address, Env, Vec}; + +pub struct FeaturedRegistry; + +impl FeaturedRegistry { + /// Mark or unmark a project as featured. Admin-only. + pub fn set_featured( + env: &Env, + admin: Address, + project_id: u64, + featured: bool, + ) -> Result<(), ContractError> { + require_admin_auth(env, &admin)?; + + // Ensure the project exists. + if !env + .storage() + .persistent() + .has(&StorageKey::Project(project_id)) + { + return Err(ContractError::ProjectNotFound); + } + + let mut ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::FeaturedProjects) + .unwrap_or(Vec::new(env)); + + let already_featured = ids.iter().any(|id| id == project_id); + + if featured && !already_featured { + ids.push_back(project_id); + env.storage() + .persistent() + .set(&StorageKey::FeaturedProjects, &ids); + } else if !featured && already_featured { + let mut updated = Vec::new(env); + for id in ids.iter() { + if id != project_id { + updated.push_back(id); + } + } + env.storage() + .persistent() + .set(&StorageKey::FeaturedProjects, &updated); + } + + publish_featured_project_event(env, project_id, featured, admin); + Ok(()) + } + + /// List featured projects with pagination. + pub fn list_featured_projects(env: &Env, start: u32, limit: u32) -> Vec { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::FeaturedProjects) + .unwrap_or(Vec::new(env)); + + let limit = limit.min(100); + let mut result = Vec::new(env); + let mut count = 0u32; + + for (i, project_id) in ids.iter().enumerate() { + if (i as u32) < start { + continue; + } + if count >= limit { + break; + } + if let Some(project) = env + .storage() + .persistent() + .get(&StorageKey::Project(project_id)) + { + result.push_back(project); + count += 1; + } + } + + result + } +} diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 64635ff..3356f4e 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -6,6 +6,7 @@ pub mod constants; pub mod errors; pub mod events; mod fee_manager; +mod featured_registry; mod project_registry; pub mod rating_calculator; mod report_registry; @@ -22,6 +23,7 @@ mod tests; use crate::admin_manager::AdminManager; use crate::errors::ContractError; use crate::fee_manager::FeeManager; +use crate::featured_registry::FeaturedRegistry; use crate::project_registry::ProjectRegistry; use crate::report_registry::ReportRegistry; use crate::review_registry::ReviewRegistry; @@ -168,6 +170,21 @@ impl DongleContract { ProjectRegistry::reactivate_project(&env, project_id, caller) } + // --- Featured Registry --- + + pub fn set_featured( + env: Env, + admin: Address, + project_id: u64, + featured: bool, + ) -> Result<(), ContractError> { + FeaturedRegistry::set_featured(&env, admin, project_id, featured) + } + + pub fn list_featured_projects(env: Env, start: u32, limit: u32) -> Vec { + FeaturedRegistry::list_featured_projects(&env, start, limit) + } + // --- Review Registry --- pub fn add_review( diff --git a/dongle-smartcontract/src/storage_keys.rs b/dongle-smartcontract/src/storage_keys.rs index e9df22a..681e0ff 100644 --- a/dongle-smartcontract/src/storage_keys.rs +++ b/dongle-smartcontract/src/storage_keys.rs @@ -68,4 +68,6 @@ pub enum StorageKey { VerificationRenewalHistory(u64, u32), /// Renewal count for a project (tracks number of renewals) VerificationRenewalCount(u64), + /// List of featured project IDs. + FeaturedProjects, } diff --git a/dongle-smartcontract/src/tests/featured.rs b/dongle-smartcontract/src/tests/featured.rs new file mode 100644 index 0000000..82ce6ca --- /dev/null +++ b/dongle-smartcontract/src/tests/featured.rs @@ -0,0 +1,120 @@ +//! Tests for the featured projects admin flow (issue #126). + +use crate::errors::ContractError; +use crate::tests::fixtures::{create_test_project, setup_contract}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +#[test] +fn test_set_featured_admin_only() { + let env = Env::default(); + let (client, _admin) = setup_contract(&env); + let non_admin = Address::generate(&env); + let owner = Address::generate(&env); + let project_id = create_test_project(&client, &owner, "Alpha"); + + let result = client + .mock_all_auths() + .try_set_featured(&non_admin, &project_id, &true); + + assert_eq!(result, Err(Ok(ContractError::AdminOnly))); +} + +#[test] +fn test_set_featured_project_not_found() { + let env = Env::default(); + let (client, admin) = setup_contract(&env); + + let result = client + .mock_all_auths() + .try_set_featured(&admin, &999u64, &true); + + assert_eq!(result, Err(Ok(ContractError::ProjectNotFound))); +} + +#[test] +fn test_set_featured_and_list() { + let env = Env::default(); + let (client, admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let id1 = create_test_project(&client, &owner, "Alpha"); + let id2 = create_test_project(&client, &owner, "Beta"); + let id3 = create_test_project(&client, &owner, "Gamma"); + + client.mock_all_auths().set_featured(&admin, &id1, &true); + client.mock_all_auths().set_featured(&admin, &id3, &true); + + let featured = client.list_featured_projects(&0, &10); + assert_eq!(featured.len(), 2); + assert_eq!(featured.get(0).unwrap().id, id1); + assert_eq!(featured.get(1).unwrap().id, id3); + + // id2 was never featured + let _ = id2; // suppress unused warning +} + +#[test] +fn test_unfeature_project() { + let env = Env::default(); + let (client, admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let id1 = create_test_project(&client, &owner, "Alpha"); + let id2 = create_test_project(&client, &owner, "Beta"); + + client.mock_all_auths().set_featured(&admin, &id1, &true); + client.mock_all_auths().set_featured(&admin, &id2, &true); + client.mock_all_auths().set_featured(&admin, &id1, &false); + + let featured = client.list_featured_projects(&0, &10); + assert_eq!(featured.len(), 1); + assert_eq!(featured.get(0).unwrap().id, id2); +} + +#[test] +fn test_set_featured_idempotent() { + let env = Env::default(); + let (client, admin) = setup_contract(&env); + let owner = Address::generate(&env); + let id = create_test_project(&client, &owner, "Alpha"); + + client.mock_all_auths().set_featured(&admin, &id, &true); + client.mock_all_auths().set_featured(&admin, &id, &true); // duplicate – no-op + + let featured = client.list_featured_projects(&0, &10); + assert_eq!(featured.len(), 1); +} + +#[test] +fn test_list_featured_pagination() { + let env = Env::default(); + let (client, admin) = setup_contract(&env); + let owner = Address::generate(&env); + + for i in 0..5u32 { + let name = match i { + 0 => "Alpha", + 1 => "Beta", + 2 => "Gamma", + 3 => "Delta", + _ => "Epsilon", + }; + let id = create_test_project(&client, &owner, name); + client.mock_all_auths().set_featured(&admin, &id, &true); + } + + let page1 = client.list_featured_projects(&0, &3); + let page2 = client.list_featured_projects(&3, &3); + + assert_eq!(page1.len(), 3); + assert_eq!(page2.len(), 2); +} + +#[test] +fn test_list_featured_empty() { + let env = Env::default(); + let (client, _admin) = setup_contract(&env); + + let featured = client.list_featured_projects(&0, &10); + assert_eq!(featured.len(), 0); +} diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index 9691ecd..ba7f502 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -5,6 +5,7 @@ mod admin; mod archival; mod error_handling_tests; mod fee; +mod featured; mod indexer; mod review; diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 5e99e52..8f1e1ed 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -183,3 +183,13 @@ pub struct ProjectAggregate { pub total_rating: u64, pub review_count: u64, } + +/// Emitted when a project's featured status changes. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeaturedProjectEvent { + pub project_id: u64, + pub featured: bool, + pub admin: Address, + pub timestamp: u64, +}