diff --git a/contracts/document/src/lib.rs b/contracts/document/src/lib.rs index 81f7ed5d..88b1816f 100644 --- a/contracts/document/src/lib.rs +++ b/contracts/document/src/lib.rs @@ -23,6 +23,8 @@ pub enum DocumentError { Unauthorized = 4, AlreadyVerified = 5, HashMismatch = 6, + AlreadyRevoked = 7, + DuplicateHash = 8, } // ── Types ───────────────────────────────────────────────────────────────────── @@ -56,6 +58,7 @@ pub struct DocumentRecord { pub is_verified: bool, pub verified_by: Option
, pub verified_at: u64, + pub is_revoked: bool, } #[contracttype] @@ -64,6 +67,7 @@ pub enum DataKey { Counter, Document(u64), ShipmentDocs(u64), // shipment_id → Vec of doc IDs + HashOwner(BytesN<32>, Address), // (content_hash, uploader) → already registered marker } const TTL_LEDGERS: u32 = 6_307_200; // ~1 year @@ -102,6 +106,11 @@ impl DocumentContract { ) -> Result { uploader.require_auth(); + let hash_key = DataKey::HashOwner(content_hash.clone(), uploader.clone()); + if env.storage().persistent().has(&hash_key) { + return Err(DocumentError::DuplicateHash); + } + let id = Self::next_id(&env); let now = env.ledger().timestamp(); @@ -116,6 +125,7 @@ impl DocumentContract { is_verified: false, verified_by: None, verified_at: 0, + is_revoked: false, }; env.storage().persistent().set(&DataKey::Document(id), &doc); @@ -123,6 +133,11 @@ impl DocumentContract { .persistent() .extend_ttl(&DataKey::Document(id), TTL_LEDGERS, TTL_LEDGERS); + env.storage().persistent().set(&hash_key, &true); + env.storage() + .persistent() + .extend_ttl(&hash_key, TTL_LEDGERS, TTL_LEDGERS); + // Append to shipment's document list. let mut list: Vec = env .storage() @@ -167,18 +182,49 @@ impl DocumentContract { Ok(()) } + // ── Revocation ───────────────────────────────────────────────────────── + + /// Admin revokes a document — e.g. if it was registered in error or later + /// found to be fraudulent. Revocation is permanent and causes + /// `check_integrity` to always return `false` for this document. + pub fn revoke_document(env: Env, revoker: Address, doc_id: u64) -> Result<(), DocumentError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(DocumentError::NotInitialized)?; + + if revoker != admin { + return Err(DocumentError::Unauthorized); + } + revoker.require_auth(); + + let mut doc = Self::load(&env, doc_id)?; + + if doc.is_revoked { + return Err(DocumentError::AlreadyRevoked); + } + + doc.is_revoked = true; + Self::store(&env, &doc); + Ok(()) + } + // ── Integrity check ─────────────────────────────────────────────────── /// Verify that a given hash matches the registered content_hash. - /// Returns `true` if the hash matches, `false` otherwise. - /// This lets anyone prove a document is untampered without downloading - /// the full file from IPFS. + /// Returns `true` only if the hash matches AND the document has not + /// been revoked. Revoked documents always fail integrity checks, + /// regardless of hash, since they should no longer be trusted. pub fn check_integrity( env: Env, doc_id: u64, hash_to_check: BytesN<32>, ) -> Result { let doc = Self::load(&env, doc_id)?; + if doc.is_revoked { + return Ok(false); + } Ok(doc.content_hash == hash_to_check) } @@ -235,180 +281,5 @@ impl DocumentContract { } } -// ── Tests ───────────────────────────────────────────────────────────────────── - #[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, BytesN as _}, - Bytes, BytesN, Env, - }; - - fn setup() -> (Env, Address, DocumentContractClient<'static>) { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let id = env.register(DocumentContract {}, ()); - let client = DocumentContractClient::new(&env, &id); - client.initialize(&admin); - (env, admin, client) - } - - fn fake_hash(env: &Env) -> BytesN<32> { - BytesN::random(env) - } - - fn fake_cid(env: &Env) -> Bytes { - // Simulate a CIDv0 string encoded as bytes. - Bytes::from_slice(env, b"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") - } - - fn register( - env: &Env, - client: &DocumentContractClient, - uploader: &Address, - shipment_id: u64, - ) -> (u64, BytesN<32>) { - let hash = fake_hash(env); - let id = client.register_document( - uploader, - &shipment_id, - &DocumentType::BillOfLading, - &hash, - &fake_cid(env), - ); - (id, hash) - } - - #[test] - fn test_register_document() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - - let (id, hash) = register(&env, &client, &uploader, 1); - - assert_eq!(id, 1); - assert_eq!(client.get_total_documents(), 1); - - let doc = client.get_document(&id); - assert_eq!(doc.id, 1); - assert_eq!(doc.shipment_id, 1); - assert_eq!(doc.uploader, uploader); - assert_eq!(doc.doc_type, DocumentType::BillOfLading); - assert_eq!(doc.content_hash, hash); - assert!(!doc.is_verified); - assert!(doc.verified_by.is_none()); - } - - #[test] - fn test_verify_document() { - let (env, admin, client) = setup(); - let uploader = Address::generate(&env); - - let (id, _) = register(&env, &client, &uploader, 1); - - client.verify_document(&admin, &id); - - let doc = client.get_document(&id); - assert!(doc.is_verified); - assert_eq!(doc.verified_by, Some(admin)); - } - - #[test] - fn test_double_verify_fails() { - let (env, admin, client) = setup(); - let uploader = Address::generate(&env); - let (id, _) = register(&env, &client, &uploader, 1); - - client.verify_document(&admin, &id); - let result = client.try_verify_document(&admin, &id); - assert_eq!(result, Err(Ok(DocumentError::AlreadyVerified))); - } - - #[test] - fn test_non_admin_verify_fails() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - let (id, _) = register(&env, &client, &uploader, 1); - - let stranger = Address::generate(&env); - let result = client.try_verify_document(&stranger, &id); - assert_eq!(result, Err(Ok(DocumentError::Unauthorized))); - } - - #[test] - fn test_integrity_check_pass() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - let (id, original_hash) = register(&env, &client, &uploader, 1); - - assert!(client.check_integrity(&id, &original_hash)); - } - - #[test] - fn test_integrity_check_tampered() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - let (id, _) = register(&env, &client, &uploader, 1); - - let tampered_hash = BytesN::random(&env); - assert!(!client.check_integrity(&id, &tampered_hash)); - } - - #[test] - fn test_multiple_docs_per_shipment() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - - let (id1, _) = register(&env, &client, &uploader, 7); - let hash2 = fake_hash(&env); - let id2 = client.register_document( - &uploader, - &7u64, - &DocumentType::ProofOfDelivery, - &hash2, - &fake_cid(&env), - ); - - let docs = client.get_documents_by_shipment(&7u64); - assert_eq!(docs.len(), 2); - assert_eq!(docs.get(0).unwrap(), id1); - assert_eq!(docs.get(1).unwrap(), id2); - } - - #[test] - fn test_all_document_types() { - let (env, _, client) = setup(); - let uploader = Address::generate(&env); - - let types = [ - DocumentType::BillOfLading, - DocumentType::ProofOfDelivery, - DocumentType::Invoice, - DocumentType::CustomsDeclaration, - DocumentType::InsuranceCertificate, - DocumentType::Photo, - DocumentType::Other, - ]; - - for doc_type in types { - let id = client.register_document( - &uploader, - &1u64, - &doc_type, - &fake_hash(&env), - &fake_cid(&env), - ); - let doc = client.get_document(&id); - assert_eq!(doc.doc_type, doc_type); - } - } - - #[test] - fn test_not_found_error() { - let (_, _, client) = setup(); - let result = client.try_get_document(&404u64); - assert_eq!(result, Err(Ok(DocumentError::NotFound))); - } -} +mod test; diff --git a/contracts/document/src/test.rs b/contracts/document/src/test.rs new file mode 100644 index 00000000..73eaef3b --- /dev/null +++ b/contracts/document/src/test.rs @@ -0,0 +1,251 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, BytesN as _}, + Bytes, BytesN, Env, +}; + +fn setup() -> (Env, Address, DocumentContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let id = env.register(DocumentContract {}, ()); + let client = DocumentContractClient::new(&env, &id); + client.initialize(&admin); + (env, admin, client) +} + +fn fake_hash(env: &Env) -> BytesN<32> { + BytesN::random(env) +} + +fn fake_cid(env: &Env) -> Bytes { + // Simulate a CIDv0 string encoded as bytes. + Bytes::from_slice(env, b"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") +} + +fn register( + env: &Env, + client: &DocumentContractClient, + uploader: &Address, + shipment_id: u64, +) -> (u64, BytesN<32>) { + let hash = fake_hash(env); + let id = client.register_document( + uploader, + &shipment_id, + &DocumentType::BillOfLading, + &hash, + &fake_cid(env), + ); + (id, hash) +} + +/// Register a document; get_document returns the correct hash and CID-bearing record. +#[test] +fn test_register_document_hash() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + let (id, hash) = register(&env, &client, &uploader, 1); + + assert_eq!(id, 1); + assert_eq!(client.get_total_documents(), 1); + + let doc = client.get_document(&id); + assert_eq!(doc.id, 1); + assert_eq!(doc.shipment_id, 1); + assert_eq!(doc.uploader, uploader); + assert_eq!(doc.doc_type, DocumentType::BillOfLading); + assert_eq!(doc.content_hash, hash); + assert!(!doc.is_verified); + assert!(doc.verified_by.is_none()); + assert!(!doc.is_revoked); +} + +/// Registering the same (hash, uploader) pair twice should fail. +#[test] +fn test_register_duplicate_hash_fails() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let hash = fake_hash(&env); + + client.register_document( + &uploader, + &1u64, + &DocumentType::BillOfLading, + &hash, + &fake_cid(&env), + ); + + let result = client.try_register_document( + &uploader, + &1u64, + &DocumentType::BillOfLading, + &hash, + &fake_cid(&env), + ); + assert_eq!(result, Err(Ok(DocumentError::DuplicateHash))); +} + +/// verify_integrity with the correct hash returns true. +#[test] +fn test_verify_integrity_correct_hash() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let (id, original_hash) = register(&env, &client, &uploader, 1); + + assert!(client.check_integrity(&id, &original_hash)); +} + +/// verify_integrity with a tampered hash returns false. +#[test] +fn test_verify_integrity_wrong_hash() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let (id, _) = register(&env, &client, &uploader, 1); + + let tampered_hash = BytesN::random(&env); + assert!(!client.check_integrity(&id, &tampered_hash)); +} + +/// Admin calls verify_document; document.is_verified becomes true. +#[test] +fn test_admin_verify_document() { + let (env, admin, client) = setup(); + let uploader = Address::generate(&env); + + let (id, _) = register(&env, &client, &uploader, 1); + + client.verify_document(&admin, &id); + + let doc = client.get_document(&id); + assert!(doc.is_verified); + assert_eq!(doc.verified_by, Some(admin)); +} + +#[test] +fn test_double_verify_fails() { + let (env, admin, client) = setup(); + let uploader = Address::generate(&env); + let (id, _) = register(&env, &client, &uploader, 1); + + client.verify_document(&admin, &id); + let result = client.try_verify_document(&admin, &id); + assert_eq!(result, Err(Ok(DocumentError::AlreadyVerified))); +} + +/// Non-admin calling verify_document should fail with Unauthorized. +#[test] +fn test_non_admin_cannot_verify() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let (id, _) = register(&env, &client, &uploader, 1); + + let stranger = Address::generate(&env); + let result = client.try_verify_document(&stranger, &id); + assert_eq!(result, Err(Ok(DocumentError::Unauthorized))); +} + +/// Revoking a document sets is_revoked = true; subsequent integrity checks fail. +#[test] +fn test_revoke_document() { + let (env, admin, client) = setup(); + let uploader = Address::generate(&env); + let (id, original_hash) = register(&env, &client, &uploader, 1); + + // Sanity: integrity check passes before revocation. + assert!(client.check_integrity(&id, &original_hash)); + + client.revoke_document(&admin, &id); + + let doc = client.get_document(&id); + assert!(doc.is_revoked); + + // Even with the correct hash, a revoked document never passes integrity. + assert!(!client.check_integrity(&id, &original_hash)); +} + +/// Revoking the same document twice should fail. +#[test] +fn test_double_revoke_fails() { + let (env, admin, client) = setup(); + let uploader = Address::generate(&env); + let (id, _) = register(&env, &client, &uploader, 1); + + client.revoke_document(&admin, &id); + let result = client.try_revoke_document(&admin, &id); + assert_eq!(result, Err(Ok(DocumentError::AlreadyRevoked))); +} + +/// Non-admin calling revoke_document should fail with Unauthorized. +#[test] +fn test_non_admin_cannot_revoke() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let (id, _) = register(&env, &client, &uploader, 1); + + let stranger = Address::generate(&env); + let result = client.try_revoke_document(&stranger, &id); + assert_eq!(result, Err(Ok(DocumentError::Unauthorized))); + + // Document remains unrevoked after the failed attempt. + assert!(!client.get_document(&id).is_revoked); +} + +#[test] +fn test_multiple_docs_per_shipment() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + let (id1, _) = register(&env, &client, &uploader, 7); + let hash2 = fake_hash(&env); + let id2 = client.register_document( + &uploader, + &7u64, + &DocumentType::ProofOfDelivery, + &hash2, + &fake_cid(&env), + ); + + let docs = client.get_documents_by_shipment(&7u64); + assert_eq!(docs.len(), 2); + assert_eq!(docs.get(0).unwrap(), id1); + assert_eq!(docs.get(1).unwrap(), id2); +} + +#[test] +fn test_all_document_types() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + let types = [ + DocumentType::BillOfLading, + DocumentType::ProofOfDelivery, + DocumentType::Invoice, + DocumentType::CustomsDeclaration, + DocumentType::InsuranceCertificate, + DocumentType::Photo, + DocumentType::Other, + ]; + + for doc_type in types { + let id = client.register_document( + &uploader, + &1u64, + &doc_type, + &fake_hash(&env), + &fake_cid(&env), + ); + let doc = client.get_document(&id); + assert_eq!(doc.doc_type, doc_type); + } +} + +#[test] +fn test_not_found_error() { + let (_, _, client) = setup(); + let result = client.try_get_document(&404u64); + assert_eq!(result, Err(Ok(DocumentError::NotFound))); +} diff --git a/contracts/identity/src/lib.rs b/contracts/identity/src/lib.rs index 4a25071f..5ac3b0e7 100644 --- a/contracts/identity/src/lib.rs +++ b/contracts/identity/src/lib.rs @@ -104,85 +104,4 @@ impl IdentityContract { } #[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, BytesN as _}, - Env, - }; - - #[test] - fn test_register_and_verify() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(IdentityContract {}, ()); - let client = IdentityContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let wallet = Address::generate(&env); - let hash = BytesN::random(&env); - - client.initialize(&admin); - client.register_identity(&hash, &wallet); - - assert!(client.verify_identity(&wallet)); - assert_eq!(client.get_user_identity(&wallet), hash); - } - - #[test] - fn test_double_register_fails() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(IdentityContract {}, ()); - let client = IdentityContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let wallet = Address::generate(&env); - let hash = BytesN::random(&env); - - client.initialize(&admin); - client.register_identity(&hash, &wallet); - - let result = client.try_register_identity(&hash, &wallet); - assert_eq!(result, Err(Ok(IdentityError::AlreadyRegistered))); - } - - #[test] - fn test_revoke_identity() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(IdentityContract {}, ()); - let client = IdentityContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let wallet = Address::generate(&env); - let hash = BytesN::random(&env); - - client.initialize(&admin); - client.register_identity(&hash, &wallet); - assert!(client.verify_identity(&wallet)); - - client.revoke_identity(&wallet); - assert!(!client.verify_identity(&wallet)); - } - - #[test] - fn test_get_unregistered_fails() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(IdentityContract {}, ()); - let client = IdentityContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let wallet = Address::generate(&env); - - client.initialize(&admin); - - let result = client.try_get_user_identity(&wallet); - assert_eq!(result, Err(Ok(IdentityError::NotRegistered))); - } -} +mod test; diff --git a/contracts/identity/src/test.rs b/contracts/identity/src/test.rs new file mode 100644 index 00000000..0e31562b --- /dev/null +++ b/contracts/identity/src/test.rs @@ -0,0 +1,124 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, BytesN as _}, + Env, IntoVal, +}; + +fn setup() -> (Env, Address, IdentityContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(IdentityContract {}, ()); + let client = IdentityContractClient::new(&env, &contract_id); + client.initialize(&admin); + + (env, admin, client) +} + +/// Register a wallet, then get_user_identity — should return the hash. +#[test] +fn test_register_new_identity() { + let (env, _admin, client) = setup(); + let wallet = Address::generate(&env); + let hash = BytesN::random(&env); + + client.register_identity(&hash, &wallet); + + assert!(client.verify_identity(&wallet)); + assert_eq!(client.get_user_identity(&wallet), hash); +} + +/// Registering the same wallet twice should fail with AlreadyRegistered. +#[test] +fn test_register_duplicate_wallet_fails() { + let (env, _admin, client) = setup(); + let wallet = Address::generate(&env); + let hash = BytesN::random(&env); + + client.register_identity(&hash, &wallet); + + let result = client.try_register_identity(&hash, &wallet); + assert_eq!(result, Err(Ok(IdentityError::AlreadyRegistered))); +} + +/// get_user_identity for an unknown wallet should return NotRegistered. +#[test] +fn test_get_unregistered_wallet_returns_none() { + let (env, _admin, client) = setup(); + let wallet = Address::generate(&env); + + let result = client.try_get_user_identity(&wallet); + assert_eq!(result, Err(Ok(IdentityError::NotRegistered))); +} + +/// Admin revokes a registration; subsequent lookups treat the wallet as unregistered. +#[test] +fn test_revoke_identity_by_admin() { + let (env, _admin, client) = setup(); + let wallet = Address::generate(&env); + let hash = BytesN::random(&env); + + client.register_identity(&hash, &wallet); + assert!(client.verify_identity(&wallet)); + + client.revoke_identity(&wallet); + + assert!(!client.verify_identity(&wallet)); + let result = client.try_get_user_identity(&wallet); + assert_eq!(result, Err(Ok(IdentityError::NotRegistered))); +} + +/// Revoking as a non-admin wallet should fail because `revoke_identity` only +/// authorizes the address stored as `Admin`. We mock authorization for a +/// non-admin signer (instead of mocking all auths), so the contract's +/// `admin.require_auth()` call has no valid signature to satisfy and the +/// invocation fails. +#[test] +fn test_revoke_by_non_admin_fails() { + let env = Env::default(); + + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); + let wallet = Address::generate(&env); + let hash = BytesN::random(&env); + + let contract_id = env.register(IdentityContract {}, ()); + let client = IdentityContractClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.initialize(&admin); + client.register_identity(&hash, &wallet); + + // Only authorize `non_admin` for this call — `admin` never signs it. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &non_admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "revoke_identity", + args: (wallet.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]); + + let result = client.try_revoke_identity(&wallet); + assert!(result.is_err()); + assert!(client.verify_identity(&wallet)); +} + +/// is_registered (via verify_identity) returns true for a registered wallet +/// and false for an unregistered one. +#[test] +fn test_is_registered_returns_correct_bool() { + let (env, _admin, client) = setup(); + let registered = Address::generate(&env); + let unregistered = Address::generate(&env); + let hash = BytesN::random(&env); + + client.register_identity(&hash, ®istered); + + assert!(client.verify_identity(®istered)); + assert!(!client.verify_identity(&unregistered)); +} diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index cc69980f..831da562 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -427,232 +427,5 @@ impl ReputationContract { } } -// ── Tests ───────────────────────────────────────────────────────────────────── - #[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::{testutils::Address as _, Env}; - - fn setup() -> (Env, Address, Address, ReputationContractClient<'static>) { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let auth_contract = Address::generate(&env); // simulates shipment contract - let contract_id = env.register(ReputationContract {}, ()); - let client = ReputationContractClient::new(&env, &contract_id); - client.initialize(&admin, &auth_contract); - - (env, admin, auth_contract, client) - } - - #[test] - fn test_register_user() { - let (env, _, _, client) = setup(); - let user = Address::generate(&env); - - client.register_user(&user, &UserType::Carrier); - - let rep = client.get_reputation(&user); - assert_eq!(rep.user, user); - assert_eq!(rep.user_type, UserType::Carrier); - assert_eq!(rep.rating_count, 0); - assert_eq!(rep.average_rating, 0); - } - - #[test] - fn test_register_twice_fails() { - let (env, _, _, client) = setup(); - let user = Address::generate(&env); - - client.register_user(&user, &UserType::Carrier); - let result = client.try_register_user(&user, &UserType::Carrier); - assert_eq!(result, Err(Ok(ReputationError::UserAlreadyRegistered))); - } - - #[test] - fn test_submit_rating_and_average() { - let (env, _, _, client) = setup(); - let rater1 = Address::generate(&env); - let rater2 = Address::generate(&env); - let rater3 = Address::generate(&env); - let carrier = Address::generate(&env); - - client.register_user(&rater1, &UserType::Shipper); - client.register_user(&rater2, &UserType::Shipper); - client.register_user(&rater3, &UserType::Shipper); - client.register_user(&carrier, &UserType::Carrier); - - // Shipment 1: score 5 - client.submit_rating(&rater1, &1u64, &carrier, &5u32); - // Shipment 2: score 4 - client.submit_rating(&rater2, &2u64, &carrier, &4u32); - // Shipment 3: score 3 - client.submit_rating(&rater3, &3u64, &carrier, &3u32); - - let rep = client.get_reputation(&carrier); - assert_eq!(rep.rating_count, 3); - // (5+4+3)*100 / 3 = 400 - assert_eq!(rep.average_rating, 400); - } - - #[test] - fn test_cannot_rate_self() { - let (env, _, _, client) = setup(); - let user = Address::generate(&env); - client.register_user(&user, &UserType::Carrier); - - let result = client.try_submit_rating(&user, &1u64, &user, &5u32); - assert_eq!(result, Err(Ok(ReputationError::CannotRateSelf))); - } - - #[test] - fn test_invalid_score() { - let (env, _, _, client) = setup(); - let rater = Address::generate(&env); - let rated = Address::generate(&env); - client.register_user(&rater, &UserType::Shipper); - client.register_user(&rated, &UserType::Carrier); - - assert_eq!( - client.try_submit_rating(&rater, &1u64, &rated, &0u32), - Err(Ok(ReputationError::InvalidScore)) - ); - assert_eq!( - client.try_submit_rating(&rater, &1u64, &rated, &6u32), - Err(Ok(ReputationError::InvalidScore)) - ); - } - - #[test] - fn test_duplicate_rating_fails() { - let (env, _, _, client) = setup(); - let rater = Address::generate(&env); - let rated = Address::generate(&env); - client.register_user(&rater, &UserType::Shipper); - client.register_user(&rated, &UserType::Carrier); - - client.submit_rating(&rater, &1u64, &rated, &5u32); - let result = client.try_submit_rating(&rater, &1u64, &rated, &4u32); - assert_eq!(result, Err(Ok(ReputationError::AlreadyRatedShipment))); - } - - #[test] - fn test_update_stats_carrier() { - let (env, _, auth_contract, client) = setup(); - let carrier = Address::generate(&env); - client.register_user(&carrier, &UserType::Carrier); - - client.update_stats(&auth_contract, &carrier, &true, &false); // on-time - client.update_stats(&auth_contract, &carrier, &true, &false); // on-time - client.update_stats(&auth_contract, &carrier, &false, &false); // late - - let rep = client.get_reputation(&carrier); - assert_eq!(rep.total_completed, 3); - assert_eq!(rep.on_time_count, 2); - assert_eq!(rep.late_count, 1); - } - - #[test] - fn test_update_stats_shipper() { - let (env, _, auth_contract, client) = setup(); - let shipper = Address::generate(&env); - client.register_user(&shipper, &UserType::Shipper); - - client.update_stats(&auth_contract, &shipper, &false, &true); // success - client.update_stats(&auth_contract, &shipper, &false, &false); // cancelled - - let rep = client.get_reputation(&shipper); - assert_eq!(rep.total_completed, 2); - assert_eq!(rep.success_count, 1); - assert_eq!(rep.cancel_count, 1); - } - - #[test] - fn test_unauthorized_update_stats_fails() { - let (env, _, _, client) = setup(); - let random = Address::generate(&env); - let carrier = Address::generate(&env); - client.register_user(&carrier, &UserType::Carrier); - - let result = client.try_update_stats(&random, &carrier, &true, &false); - assert_eq!(result, Err(Ok(ReputationError::Unauthorized))); - } - - #[test] - fn test_calculate_score_perfect_carrier() { - let (env, _, auth_contract, client) = setup(); - let rater = Address::generate(&env); - let carrier = Address::generate(&env); - client.register_user(&rater, &UserType::Shipper); - client.register_user(&carrier, &UserType::Carrier); - - // 5-star rating - client.submit_rating(&rater, &1u64, &carrier, &5u32); - // Perfect on-time record - client.update_stats(&auth_contract, &carrier, &true, &false); - - let score = client.calculate_score(&carrier); - // avg_rating = 500 (5 stars × 100), on_time_pct = 100%, rating/completed = 100% - // rating_component = 500, rate_component = 300, completion_component = 200 - // total = 1000 - assert_eq!(score, 1000); - } - - #[test] - fn test_calculate_score_new_user() { - let (env, _, _, client) = setup(); - let user = Address::generate(&env); - client.register_user(&user, &UserType::Carrier); - - let score = client.calculate_score(&user); - assert_eq!(score, 0); // no data yet - } - - #[test] - fn test_has_rated_shipment() { - let (env, _, _, client) = setup(); - let rater = Address::generate(&env); - let rated = Address::generate(&env); - client.register_user(&rater, &UserType::Shipper); - client.register_user(&rated, &UserType::Carrier); - - assert!(!client.has_rated_shipment(&1u64, &rater)); - client.submit_rating(&rater, &1u64, &rated, &4u32); - assert!(client.has_rated_shipment(&1u64, &rater)); - } - - #[test] - fn test_get_rating() { - let (env, _, _, client) = setup(); - let rater = Address::generate(&env); - let rated = Address::generate(&env); - client.register_user(&rater, &UserType::Shipper); - client.register_user(&rated, &UserType::Carrier); - - let rating_id = client.submit_rating(&rater, &5u64, &rated, &4u32); - let record = client.get_rating(&rating_id); - - assert_eq!(record.shipment_id, 5); - assert_eq!(record.rater, rater); - assert_eq!(record.rated, rated); - assert_eq!(record.score, 4); - } - - #[test] - fn test_total_ratings_counter() { - let (env, _, _, client) = setup(); - let rater1 = Address::generate(&env); - let rater2 = Address::generate(&env); - let rated = Address::generate(&env); - client.register_user(&rater1, &UserType::Shipper); - client.register_user(&rater2, &UserType::Shipper); - client.register_user(&rated, &UserType::Carrier); - - assert_eq!(client.get_total_ratings(), 0); - client.submit_rating(&rater1, &1u64, &rated, &5u32); - client.submit_rating(&rater2, &2u64, &rated, &3u32); - assert_eq!(client.get_total_ratings(), 2); - } -} +mod test; diff --git a/contracts/reputation/src/test.rs b/contracts/reputation/src/test.rs new file mode 100644 index 00000000..5a7573dc --- /dev/null +++ b/contracts/reputation/src/test.rs @@ -0,0 +1,245 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Env}; + +fn setup() -> (Env, Address, Address, ReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let auth_contract = Address::generate(&env); // simulates shipment contract + let contract_id = env.register(ReputationContract {}, ()); + let client = ReputationContractClient::new(&env, &contract_id); + client.initialize(&admin, &auth_contract); + + (env, admin, auth_contract, client) +} + +/// Record one rating; get_reputation should return the correct totals. +#[test] +fn test_record_single_rating() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + client.submit_rating(&rater, &1u64, &carrier, &5u32); + + let rep = client.get_reputation(&carrier); + assert_eq!(rep.rating_count, 1); + assert_eq!(rep.average_rating, 500); +} + +#[test] +fn test_register_user() { + let (env, _, _, client) = setup(); + let user = Address::generate(&env); + + client.register_user(&user, &UserType::Carrier); + + let rep = client.get_reputation(&user); + assert_eq!(rep.user, user); + assert_eq!(rep.user_type, UserType::Carrier); + assert_eq!(rep.rating_count, 0); + assert_eq!(rep.average_rating, 0); +} + +#[test] +fn test_register_twice_fails() { + let (env, _, _, client) = setup(); + let user = Address::generate(&env); + + client.register_user(&user, &UserType::Carrier); + let result = client.try_register_user(&user, &UserType::Carrier); + assert_eq!(result, Err(Ok(ReputationError::UserAlreadyRegistered))); +} + +/// Record 5 ratings (mix of star values); verify the running average. +#[test] +fn test_record_multiple_ratings_aggregates_correctly() { + let (env, _, _, client) = setup(); + let rater1 = Address::generate(&env); + let rater2 = Address::generate(&env); + let rater3 = Address::generate(&env); + let carrier = Address::generate(&env); + + client.register_user(&rater1, &UserType::Shipper); + client.register_user(&rater2, &UserType::Shipper); + client.register_user(&rater3, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + client.submit_rating(&rater1, &1u64, &carrier, &5u32); + client.submit_rating(&rater2, &2u64, &carrier, &4u32); + client.submit_rating(&rater3, &3u64, &carrier, &3u32); + + let rep = client.get_reputation(&carrier); + assert_eq!(rep.rating_count, 3); + // (5+4+3)*100 / 3 = 400 + assert_eq!(rep.average_rating, 400); +} + +#[test] +fn test_cannot_rate_self() { + let (env, _, _, client) = setup(); + let user = Address::generate(&env); + client.register_user(&user, &UserType::Carrier); + + let result = client.try_submit_rating(&user, &1u64, &user, &5u32); + assert_eq!(result, Err(Ok(ReputationError::CannotRateSelf))); +} + +/// Recording a rating of 0 or 6 should fail with InvalidScore. +#[test] +fn test_star_rating_out_of_range_fails() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + assert_eq!( + client.try_submit_rating(&rater, &1u64, &rated, &0u32), + Err(Ok(ReputationError::InvalidScore)) + ); + assert_eq!( + client.try_submit_rating(&rater, &1u64, &rated, &6u32), + Err(Ok(ReputationError::InvalidScore)) + ); +} + +/// Submitting the same shipment_id twice for the same ratee should fail. +#[test] +fn test_cannot_rate_same_shipment_twice() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + client.submit_rating(&rater, &1u64, &rated, &5u32); + let result = client.try_submit_rating(&rater, &1u64, &rated, &4u32); + assert_eq!(result, Err(Ok(ReputationError::AlreadyRatedShipment))); +} + +#[test] +fn test_update_stats_carrier() { + let (env, _, auth_contract, client) = setup(); + let carrier = Address::generate(&env); + client.register_user(&carrier, &UserType::Carrier); + + client.update_stats(&auth_contract, &carrier, &true, &false); // on-time + client.update_stats(&auth_contract, &carrier, &true, &false); // on-time + client.update_stats(&auth_contract, &carrier, &false, &false); // late + + let rep = client.get_reputation(&carrier); + assert_eq!(rep.total_completed, 3); + assert_eq!(rep.on_time_count, 2); + assert_eq!(rep.late_count, 1); +} + +#[test] +fn test_update_stats_shipper() { + let (env, _, auth_contract, client) = setup(); + let shipper = Address::generate(&env); + client.register_user(&shipper, &UserType::Shipper); + + client.update_stats(&auth_contract, &shipper, &false, &true); // success + client.update_stats(&auth_contract, &shipper, &false, &false); // cancelled + + let rep = client.get_reputation(&shipper); + assert_eq!(rep.total_completed, 2); + assert_eq!(rep.success_count, 1); + assert_eq!(rep.cancel_count, 1); +} + +#[test] +fn test_unauthorized_update_stats_fails() { + let (env, _, _, client) = setup(); + let random = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&carrier, &UserType::Carrier); + + let result = client.try_update_stats(&random, &carrier, &true, &false); + assert_eq!(result, Err(Ok(ReputationError::Unauthorized))); +} + +/// Record known ratings and verify the composite score matches the formula. +#[test] +fn test_composite_score_formula() { + let (env, _, auth_contract, client) = setup(); + let rater = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + // 5-star rating + client.submit_rating(&rater, &1u64, &carrier, &5u32); + // Perfect on-time record + client.update_stats(&auth_contract, &carrier, &true, &false); + + let score = client.calculate_score(&carrier); + // avg_rating = 500 (5 stars × 100), on_time_pct = 100%, rating/completed = 100% + // rating_component = 500, rate_component = 300, completion_component = 200 + // total = 1000 + assert_eq!(score, 1000); +} + +/// get_score (calculate_score) for a wallet that has never been rated +/// returns 0, the documented default. +#[test] +fn test_get_score_for_unrated_carrier() { + let (env, _, _, client) = setup(); + let user = Address::generate(&env); + client.register_user(&user, &UserType::Carrier); + + let score = client.calculate_score(&user); + assert_eq!(score, 0); // no data yet +} + +#[test] +fn test_has_rated_shipment() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + assert!(!client.has_rated_shipment(&1u64, &rater)); + client.submit_rating(&rater, &1u64, &rated, &4u32); + assert!(client.has_rated_shipment(&1u64, &rater)); +} + +#[test] +fn test_get_rating() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + let rating_id = client.submit_rating(&rater, &5u64, &rated, &4u32); + let record = client.get_rating(&rating_id); + + assert_eq!(record.shipment_id, 5); + assert_eq!(record.rater, rater); + assert_eq!(record.rated, rated); + assert_eq!(record.score, 4); +} + +#[test] +fn test_total_ratings_counter() { + let (env, _, _, client) = setup(); + let rater1 = Address::generate(&env); + let rater2 = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater1, &UserType::Shipper); + client.register_user(&rater2, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + assert_eq!(client.get_total_ratings(), 0); + client.submit_rating(&rater1, &1u64, &rated, &5u32); + client.submit_rating(&rater2, &2u64, &rated, &3u32); + assert_eq!(client.get_total_ratings(), 2); +}