From a27fb7c4b7ad30be41395d958237fc0f2c3cd38b Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Mon, 1 Jun 2026 13:29:11 +0000 Subject: [PATCH] feat: add project archival feature (closes #121) - Add is_archived field to Project struct - Add archive_project function: owner or admin can archive - Filter archived projects from list_projects and list_projects_by_category - Expose archive_project in contract interface (lib.rs) - Add ProjectArchivedEvent and publish function - Add 6 tests covering owner archive, unauthorized, admin force-archive, list exclusion --- dongle-smartcontract/src/events.rs | 25 ++++++ dongle-smartcontract/src/lib.rs | 8 ++ dongle-smartcontract/src/project_registry.rs | 64 +++++++++++--- dongle-smartcontract/src/tests/archival.rs | 93 ++++++++++++++++++++ dongle-smartcontract/src/tests/mod.rs | 1 + dongle-smartcontract/src/types.rs | 1 + 6 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 dongle-smartcontract/src/tests/archival.rs diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index fac4d3b..f7368d1 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -73,6 +73,15 @@ pub struct ProjectOwnershipTransferredEvent { pub timestamp: u64, } +/// Emitted when a project is archived. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectArchivedEvent { + pub project_id: u64, + pub archived_by: Address, + pub timestamp: u64, +} + /// Emitted when an admin is added. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -186,6 +195,22 @@ pub fn publish_project_updated_event(env: &Env, project_id: u64, owner: Address) ); } +pub fn publish_project_archived_event(env: &Env, project_id: u64, archived_by: Address) { + let event_data = ProjectArchivedEvent { + project_id, + archived_by, + timestamp: env.ledger().timestamp(), + }; + env.events().publish( + ( + symbol_short!("PROJECT"), + symbol_short!("ARCHIVED"), + project_id, + ), + event_data, + ); +} + // ── Fee events ──────────────────────────────────────────────────────────────── pub fn publish_fee_paid_event(env: &Env, project_id: u64, payer: Address, amount: u128) { diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 80188a3..fa2d18e 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -146,6 +146,14 @@ impl DongleContract { ProjectRegistry::list_projects_by_category(&env, category, start_id, limit) } + pub fn archive_project( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::archive_project(&env, project_id, caller) + } + // --- Review Registry --- pub fn add_review( diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index e9fa41a..b5ecaa4 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -1,8 +1,8 @@ use crate::constants::MAX_PROJECTS_PER_USER; use crate::errors::ContractError; use crate::events::{ - publish_ownership_transferred_event, publish_project_registered_event, - publish_project_updated_event, + publish_ownership_transferred_event, publish_project_archived_event, + publish_project_registered_event, publish_project_updated_event, }; use crate::fee_manager::FeeManager; use crate::storage_keys::StorageKey; @@ -82,6 +82,7 @@ impl ProjectRegistry { logo_cid: params.logo_cid, metadata_cid: params.metadata_cid, verification_status: VerificationStatus::Unverified, + is_archived: false, created_at: now, updated_at: now, }; @@ -432,14 +433,16 @@ impl ProjectRegistry { return projects; } - let end = core::cmp::min( - first.saturating_add(effective_limit as u64), - count.saturating_add(1), - ); - - for id in first..end { + let mut collected: u32 = 0; + for id in first..=count { + if collected >= effective_limit { + break; + } if let Some(project) = Self::get_project(env, id) { - projects.push_back(project); + if !project.is_archived { + projects.push_back(project); + collected += 1; + } } } projects @@ -469,12 +472,17 @@ impl ProjectRegistry { return projects; } - let end = core::cmp::min(start_id.saturating_add(effective_limit), len); - - for i in start_id..end { + let mut collected: u32 = 0; + for i in start_id..len { + if collected >= effective_limit { + break; + } if let Some(id) = category_projects.get(i) { if let Some(project) = Self::get_project(env, id) { - projects.push_back(project); + if !project.is_archived { + projects.push_back(project); + collected += 1; + } } } } @@ -603,6 +611,36 @@ impl ProjectRegistry { publish_ownership_transferred_event(env, project_id, old_owner, pending_new_owner); Ok(()) } + + /// Archive a project. The owner can archive their own project; admins can force-archive any project. + pub fn archive_project( + env: &Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + let mut project = + Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; + + caller.require_auth(); + + let is_owner = project.owner == caller; + let is_admin = crate::admin_manager::AdminManager::is_admin(env, &caller); + + if !is_owner && !is_admin { + return Err(ContractError::Unauthorized); + } + + project.is_archived = true; + project.updated_at = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&StorageKey::Project(project_id), &project); + + StorageManager::extend_project_ttl(env, project_id); + + publish_project_archived_event(env, project_id, caller); + Ok(()) + } } // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/dongle-smartcontract/src/tests/archival.rs b/dongle-smartcontract/src/tests/archival.rs new file mode 100644 index 0000000..6ec0c14 --- /dev/null +++ b/dongle-smartcontract/src/tests/archival.rs @@ -0,0 +1,93 @@ +//! Tests for project archival feature (issue #121). + +use crate::errors::ContractError; +use crate::tests::fixtures::{create_test_project, setup_contract}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +#[test] +fn test_owner_can_archive_project() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let project_id = create_test_project(&client, &owner, "MyProject"); + client.archive_project(&project_id, &owner); + + let project = client.get_project(&project_id).unwrap(); + assert!(project.is_archived); +} + +#[test] +fn test_unauthorized_cannot_archive_project() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let owner = Address::generate(&env); + let stranger = Address::generate(&env); + + let project_id = create_test_project(&client, &owner, "MyProject"); + + let result = client.try_archive_project(&project_id, &stranger); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn test_admin_can_force_archive_project() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let project_id = create_test_project(&client, &owner, "MyProject"); + client.archive_project(&project_id, &admin); + + let project = client.get_project(&project_id).unwrap(); + assert!(project.is_archived); +} + +#[test] +fn test_archived_project_excluded_from_list_projects() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let id1 = create_test_project(&client, &owner, "ProjectA"); + let id2 = create_test_project(&client, &owner, "ProjectB"); + + client.archive_project(&id1, &owner); + + let projects = client.list_projects(&0, &10); + assert_eq!(projects.len(), 1); + assert_eq!(projects.get(0).unwrap().id, id2); +} + +#[test] +fn test_archived_project_excluded_from_list_by_category() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let owner = Address::generate(&env); + + let id1 = create_test_project(&client, &owner, "ProjectA"); + let id2 = create_test_project(&client, &owner, "ProjectB"); + + client.archive_project(&id1, &owner); + + let category = soroban_sdk::String::from_str(&env, "DeFi"); + let projects = client.list_projects_by_category(&category, &0, &10); + assert_eq!(projects.len(), 1); + assert_eq!(projects.get(0).unwrap().id, id2); +} + +#[test] +fn test_archive_nonexistent_project_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let caller = Address::generate(&env); + + let result = client.try_archive_project(&999, &caller); + assert_eq!(result, Err(Ok(ContractError::ProjectNotFound))); +} diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index c0c4480..9c80353 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -2,6 +2,7 @@ // Existing test modules mod admin; +mod archival; mod error_handling_tests; mod fee; mod indexer; diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 3eda94d..c1eefd6 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -84,6 +84,7 @@ pub struct Project { pub logo_cid: Option, pub metadata_cid: Option, pub verification_status: VerificationStatus, + pub is_archived: bool, pub created_at: u64, pub updated_at: u64, }