From 5907581775fceadbf4ac27cb976510318c5899c3 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Mon, 1 Jun 2026 14:08:02 +0000 Subject: [PATCH] feat: add featured projects admin flow (#126) - Add FeaturedProjects storage key to StorageKey enum - Add FeaturedProjectEvent type to types.rs - Add publish_featured_project_event to events.rs - Add featured_registry.rs with set_featured and list_featured_projects - Expose set_featured and list_featured_projects in lib.rs - Add 6 tests covering admin-only access, not-found, set/unfeature, idempotency, pagination, and empty list --- dongle-smartcontract/src/events.rs | 19 +++ dongle-smartcontract/src/featured_registry.rs | 91 +++++++++++++ dongle-smartcontract/src/lib.rs | 17 +++ dongle-smartcontract/src/storage_keys.rs | 2 + dongle-smartcontract/src/tests/featured.rs | 120 ++++++++++++++++++ dongle-smartcontract/src/tests/mod.rs | 1 + dongle-smartcontract/src/types.rs | 10 ++ 7 files changed, 260 insertions(+) create mode 100644 dongle-smartcontract/src/featured_registry.rs create mode 100644 dongle-smartcontract/src/tests/featured.rs diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index fac4d3b..8537e38 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -314,3 +314,22 @@ pub fn publish_admin_removed_event(env: &Env, admin: Address) { event_data, ); } + +// ── Featured project events ─────────────────────────────────────────────────── + +pub fn publish_featured_project_event(env: &Env, project_id: u64, featured: bool, admin: Address) { + use crate::types::FeaturedProjectEvent; + let event_data = FeaturedProjectEvent { + project_id, + featured, + admin, + timestamp: env.ledger().timestamp(), + }; + let action = if featured { + symbol_short!("FEATURED") + } else { + symbol_short!("UNFEATRD") + }; + env.events() + .publish((symbol_short!("PROJECT"), action, project_id), event_data); +} 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 80188a3..f3b1336 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; pub mod review_registry; @@ -21,6 +22,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::review_registry::ReviewRegistry; use crate::storage_manager::StorageManager; @@ -146,6 +148,21 @@ impl DongleContract { ProjectRegistry::list_projects_by_category(&env, category, start_id, limit) } + // --- 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 7a240c8..aa7708a 100644 --- a/dongle-smartcontract/src/storage_keys.rs +++ b/dongle-smartcontract/src/storage_keys.rs @@ -44,4 +44,6 @@ pub enum StorageKey { PendingTransfer(u64), /// List of project IDs by category. CategoryProjects(String), + /// 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 c0c4480..e561e97 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -4,6 +4,7 @@ mod admin; mod error_handling_tests; mod fee; +mod featured; mod indexer; mod registration; mod review; diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 3eda94d..e2f5574 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -140,3 +140,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, +}