Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions dongle-smartcontract/src/featured_registry.rs
Original file line number Diff line number Diff line change
@@ -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<u64> = 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<Project> {
let ids: Vec<u64> = 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
}
}
17 changes: 17 additions & 0 deletions dongle-smartcontract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Project> {
FeaturedRegistry::list_featured_projects(&env, start, limit)
}

// --- Review Registry ---

pub fn add_review(
Expand Down
2 changes: 2 additions & 0 deletions dongle-smartcontract/src/storage_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
120 changes: 120 additions & 0 deletions dongle-smartcontract/src/tests/featured.rs
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions dongle-smartcontract/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod admin;
mod archival;
mod error_handling_tests;
mod fee;
mod featured;
mod indexer;
mod review;

Expand Down
10 changes: 10 additions & 0 deletions dongle-smartcontract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading