diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index 7852bac..102b093 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -78,6 +78,7 @@ pub struct ProjectOwnershipTransferredEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectArchivedEvent { pub project_id: u64, + pub archived_by: Address, pub owner: Address, pub timestamp: u64, } @@ -243,6 +244,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/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index 9febba9..352bfb0 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -2,6 +2,7 @@ use crate::constants::MAX_PROJECTS_PER_USER; use crate::errors::ContractError; use crate::events::{ publish_ownership_transferred_event, publish_project_archived_event, + publish_project_registered_event, publish_project_updated_event, publish_project_reactivated_event, publish_project_registered_event, publish_project_updated_event, }; @@ -104,6 +105,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, tags: params.tags.clone(), @@ -571,6 +573,8 @@ impl ProjectRegistry { return projects; } + let mut collected: u32 = 0; + for id in first..=count { let end = core::cmp::min( first.saturating_add(effective_limit as u64), count.saturating_add(1), @@ -582,6 +586,7 @@ impl ProjectRegistry { break; } if let Some(project) = Self::get_project(env, id) { + if !project.is_archived { if !project.archived { projects.push_back(project); collected += 1; @@ -615,6 +620,8 @@ impl ProjectRegistry { return projects; } + let mut collected: u32 = 0; + for i in start_id..len { let end = core::cmp::min(start_id.saturating_add(effective_limit), len); let mut collected: u32 = 0; @@ -624,6 +631,7 @@ impl ProjectRegistry { } if let Some(id) = category_projects.get(i) { if let Some(project) = Self::get_project(env, id) { + if !project.is_archived { if !project.archived { projects.push_back(project); collected += 1; @@ -757,6 +765,34 @@ impl ProjectRegistry { 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(()) /// List projects by tag - Issue #125 pub fn list_projects_by_tag( env: &Env, 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 8da1bc5..6fb3d28 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -2,6 +2,8 @@ // Existing test modules mod admin; +mod archival; +mod error_handling_tests; mod fee; mod indexer; mod review; diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 36457df..5e99e52 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -98,6 +98,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, pub tags: Option>,