diff --git a/contracts/opsce/src/kyc_verification.rs b/contracts/opsce/src/kyc_verification.rs new file mode 100644 index 00000000..7048aea3 --- /dev/null +++ b/contracts/opsce/src/kyc_verification.rs @@ -0,0 +1,188 @@ +//! KYC verification module +//! +//! Implements the on-chain KYC workflow: +//! +//! - Whitelisted oracles submit results via [`submit_kyc_result`]. +//! - Asset-transfer style functions can call [`require_kyc`] as a guard. +//! - Approval expires after the stored `expiry` ledger timestamp; once +//! expired, [`get_kyc_status`] reports `Expired` and [`require_kyc`] +//! rejects. + +use soroban_sdk::{contracttype, Address, Env, Symbol}; + +use crate::error::ContractError; +use crate::provider_rating::read_admin; + +/// Lifecycle of a KYC record. +/// +/// `Expired` is a derived state surfaced by `get_kyc_status` when an +/// `Approved` record's expiry has passed; it cannot be submitted directly. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum KycStatus { + Pending, + Approved, + Rejected, + Expired, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KycRecord { + pub address: Address, + pub status: KycStatus, + /// Ledger timestamp after which the KYC approval is no longer valid. + pub expiry: u64, + pub updated_at: u64, +} + +/// Read model returned by `get_kyc_status`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KycStatusInfo { + pub status: KycStatus, + pub expiry: u64, +} + +#[contracttype] +pub enum KycDataKey { + /// Stores `bool` true when an address is whitelisted as a KYC oracle. + Oracle(Address), + /// Stores the `KycRecord` for an address. + Record(Address), +} + +/// Whitelist a KYC oracle. Admin only. +pub fn add_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &true); + Ok(()) +} + +/// Remove a KYC oracle from the whitelist. Admin only. +pub fn remove_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &false); + Ok(()) +} + +pub fn is_kyc_oracle(env: &Env, oracle: Address) -> bool { + env.storage() + .persistent() + .get(&KycDataKey::Oracle(oracle)) + .unwrap_or(false) +} + +/// Submit the outcome of an off-chain KYC check. +/// +/// `oracle` must be whitelisted via [`add_kyc_oracle`] and must authorize the +/// call. `status` must be either `Approved` or `Rejected`; all other variants +/// return `Err(ContractError::InvalidKycStatus)`. +pub fn submit_kyc_result( + env: &Env, + oracle: Address, + address: Address, + status: KycStatus, + expiry: u64, +) -> Result<(), ContractError> { + oracle.require_auth(); + + // Reject any caller that is not on the oracle whitelist. + let is_oracle: bool = env + .storage() + .persistent() + .get(&KycDataKey::Oracle(oracle.clone())) + .unwrap_or(false); + if !is_oracle { + return Err(ContractError::NotOracle); + } + + // Only Approved and Rejected may be submitted. + match status { + KycStatus::Approved | KycStatus::Rejected => {} + _ => return Err(ContractError::InvalidKycStatus), + } + + let record = KycRecord { + address: address.clone(), + status: status.clone(), + expiry, + updated_at: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&KycDataKey::Record(address.clone()), &record); + + // Emit the appropriate event. + match status { + KycStatus::Approved => { + env.events() + .publish((Symbol::new(env, "kyc_approved"), address), expiry); + } + KycStatus::Rejected => { + env.events() + .publish((Symbol::new(env, "kyc_rejected"), address), expiry); + } + _ => {} + } + + Ok(()) +} + +/// Guard helper for transfer-style functions. +/// +/// Returns `Ok(())` only when the address has an `Approved` KYC record whose +/// expiry timestamp is still in the future. Otherwise returns +/// `Err(ContractError::KycNotApproved)`. +pub fn require_kyc(env: &Env, address: Address) -> Result<(), ContractError> { + let record: KycRecord = env + .storage() + .persistent() + .get(&KycDataKey::Record(address)) + .ok_or(ContractError::KycNotApproved)?; + + match record.status { + KycStatus::Approved => { + if env.ledger().timestamp() > record.expiry { + Err(ContractError::KycNotApproved) + } else { + Ok(()) + } + } + _ => Err(ContractError::KycNotApproved), + } +} + +/// Returns the current effective status and expiry timestamp for an address. +/// An approved record whose expiry has passed is reported as `Expired`. +/// Unknown addresses return `Pending` with expiry `0`. +pub fn get_kyc_status(env: &Env, address: Address) -> KycStatusInfo { + match env + .storage() + .persistent() + .get::<_, KycRecord>(&KycDataKey::Record(address)) + { + Some(record) => { + let now = env.ledger().timestamp(); + let status = if matches!(record.status, KycStatus::Approved) && now > record.expiry { + KycStatus::Expired + } else { + record.status + }; + KycStatusInfo { + status, + expiry: record.expiry, + } + } + None => KycStatusInfo { + status: KycStatus::Pending, + expiry: 0, + }, + } +} diff --git a/contracts/opsce/src/provider_rating.rs b/contracts/opsce/src/provider_rating.rs index 4a265ce6..89df03b4 100644 --- a/contracts/opsce/src/provider_rating.rs +++ b/contracts/opsce/src/provider_rating.rs @@ -96,298 +96,176 @@ pub enum DataKey { Review(u64), } -#[contract] -pub struct OpsceContract; - -#[contractimpl] -impl OpsceContract { - /// One-time initialization. Stores the admin used to register providers - /// and seed maintenance records. - pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { - if env.storage().persistent().has(&DataKey::Admin) { - return Err(ContractError::AlreadyInitialized); - } - env.storage().persistent().set(&DataKey::Admin, &admin); - Ok(()) - } - - /// Register a new provider with a zeroed rating profile. Admin only. - pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(ContractError::NotInitialized)?; - admin.require_auth(); - - let profile = ProviderProfile { - address: provider.clone(), - total_reviews: 0, - rating_sum: 0, - average_rating: 0, - }; - env.storage() - .persistent() - .set(&DataKey::Provider(provider), &profile); - Ok(()) - } - - /// Persist a maintenance record (already completed). Admin only. - /// In production this would be invoked by the asset-maintenance contract - /// when a record is marked complete; here it acts as the entry point that - /// makes a record eligible for rating. - pub fn record_completed_maintenance( - env: Env, - record_id: u64, - asset_id: u64, - owner: Address, - provider: Address, - ) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(ContractError::NotInitialized)?; - admin.require_auth(); - - if !env - .storage() - .persistent() - .has(&DataKey::Provider(provider.clone())) - { - return Err(ContractError::ProviderNotFound); - } - - let record = MaintenanceRecord { - record_id, - asset_id, - provider, - owner, - completed: true, - }; - env.storage() - .persistent() - .set(&DataKey::Record(record_id), &record); - Ok(()) - } - - /// Rate a provider on a completed maintenance record. - /// - /// Authorization: the asset owner stored on the record must authorize the - /// call (`require_auth`). Rating must be in the inclusive range 1..=5 and - /// each record can be rated only once. - pub fn rate_provider( - env: Env, - record_id: u64, - rating: u32, - comment: String, - ) -> Result<(), ContractError> { - // Validate rating bounds first so 0 and 6+ are rejected before any - // storage access. - if rating < 1 || rating > 5 { - return Err(ContractError::InvalidRating); - } - - let record: MaintenanceRecord = env - .storage() - .persistent() - .get(&DataKey::Record(record_id)) - .ok_or(ContractError::RecordNotFound)?; - - if !record.completed { - return Err(ContractError::RecordNotComplete); - } - - // Caller must be the asset owner stored on the record. - record.owner.require_auth(); - - if env - .storage() - .persistent() - .has(&DataKey::RatedRecord(record_id)) - { - return Err(ContractError::AlreadyRated); - } - - let mut profile: ProviderProfile = env - .storage() - .persistent() - .get(&DataKey::Provider(record.provider.clone())) - .ok_or(ContractError::ProviderNotFound)?; - - // Update the running totals and average (scaled by 100). - profile.rating_sum = profile.rating_sum.saturating_add(rating); - profile.total_reviews = profile.total_reviews.saturating_add(1); - profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews; - - env.storage() - .persistent() - .set(&DataKey::Provider(record.provider.clone()), &profile); - - // Mark this record as rated and persist the review entry. - env.storage() - .persistent() - .set(&DataKey::RatedRecord(record_id), &true); - - let review = Review { - record_id, - provider: record.provider.clone(), - rater: record.owner.clone(), - rating, - comment, - timestamp: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&DataKey::Review(record_id), &review); - - // Emit the `provider_rated` event with the rating value and record id. - let topic = Symbol::new(&env, "provider_rated"); - env.events() - .publish((topic, record.provider), (rating, record_id)); - - Ok(()) - } - - /// Returns the current `{ average_rating, total_reviews }` for a provider. - /// Unknown providers return zero values rather than an error so callers - /// can use this as a cheap query. - pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating { - match env - .storage() - .persistent() - .get::<_, ProviderProfile>(&DataKey::Provider(provider_address)) - { - Some(p) => ProviderRating { - average_rating: p.average_rating, - total_reviews: p.total_reviews, - }, - None => ProviderRating { - average_rating: 0, - total_reviews: 0, - }, - } - } +/// Read the admin address registered through [`init`]. +pub fn read_admin(env: &Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized) +} - /// Fetch the persisted review for a record, if any. - pub fn get_review(env: Env, record_id: u64) -> Option { - env.storage().persistent().get(&DataKey::Review(record_id)) +/// One-time initialization. Stores the admin used to register providers and +/// seed maintenance records. +pub fn init(env: &Env, admin: Address) -> Result<(), ContractError> { + if env.storage().persistent().has(&DataKey::Admin) { + return Err(ContractError::AlreadyInitialized); } + env.storage().persistent().set(&DataKey::Admin, &admin); + Ok(()) } -#[cfg(test)] -mod tests { - extern crate std; - - use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::{Address, Env, String}; +/// Register a new provider with a zeroed rating profile. Admin only. +pub fn register_provider(env: &Env, provider: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + + let profile = ProviderProfile { + address: provider.clone(), + total_reviews: 0, + rating_sum: 0, + average_rating: 0, + }; + env.storage() + .persistent() + .set(&DataKey::Provider(provider), &profile); + Ok(()) +} - struct Fixture { - env: Env, - client: OpsceContractClient<'static>, - owner: Address, - provider: Address, - record_id: u64, +/// Persist a maintenance record (already completed). Admin only. +/// +/// In production this would be invoked by the asset-maintenance contract when +/// a record is marked complete; here it acts as the entry point that makes a +/// record eligible for rating. +pub fn record_completed_maintenance( + env: &Env, + record_id: u64, + asset_id: u64, + owner: Address, + provider: Address, +) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + + if !env + .storage() + .persistent() + .has(&DataKey::Provider(provider.clone())) + { + return Err(ContractError::ProviderNotFound); } - fn setup() -> Fixture { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(OpsceContract, ()); - let client = OpsceContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let provider = Address::generate(&env); - let owner = Address::generate(&env); - - client.init(&admin); - client.register_provider(&provider); - - let record_id: u64 = 1; - client.record_completed_maintenance(&record_id, &101u64, &owner, &provider); + let record = MaintenanceRecord { + record_id, + asset_id, + provider, + owner, + completed: true, + }; + env.storage() + .persistent() + .set(&DataKey::Record(record_id), &record); + Ok(()) +} - Fixture { - env, - client, - owner, - provider, - record_id, - } +/// Rate a provider on a completed maintenance record. +/// +/// Authorization: the asset owner stored on the record must authorize the +/// call (`require_auth`). Rating must be in the inclusive range 1..=5 and each +/// record can be rated only once. +pub fn rate_provider( + env: &Env, + record_id: u64, + rating: u32, + comment: String, +) -> Result<(), ContractError> { + // Validate rating bounds first so 0 and 6+ are rejected before any + // storage access. + if rating < 1 || rating > 5 { + return Err(ContractError::InvalidRating); } - #[test] - fn rate_provider_valid_rating_updates_running_average() { - let fx = setup(); - - // First rating: 5 stars. - fx.client - .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + let record: MaintenanceRecord = env + .storage() + .persistent() + .get(&DataKey::Record(record_id)) + .ok_or(ContractError::RecordNotFound)?; - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 1); - assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100 - - // Second rating on a different record: 4 stars => running avg 4.5. - let record_id2: u64 = 2; - fx.client - .record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider); - fx.client - .rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good")); - - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 2); - assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450 + if !record.completed { + return Err(ContractError::RecordNotComplete); } - #[test] - fn rate_provider_duplicate_returns_already_rated() { - let fx = setup(); - - fx.client - .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + // Caller must be the asset owner stored on the record. + record.owner.require_auth(); - // try_ returns the error variant without panicking on contract errors. - let result = fx.client.try_rate_provider( - &fx.record_id, - &4u32, - &String::from_str(&fx.env, "Again"), - ); - assert_eq!(result, Err(Ok(ContractError::AlreadyRated))); - - // The provider's average must remain at the first rating only. - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 1); - assert_eq!(rating.average_rating, 500); + if env + .storage() + .persistent() + .has(&DataKey::RatedRecord(record_id)) + { + return Err(ContractError::AlreadyRated); } - #[test] - fn rate_provider_rejects_rating_below_one() { - let fx = setup(); - - let result = fx - .client - .try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, "")); - assert_eq!(result, Err(Ok(ContractError::InvalidRating))); + let mut profile: ProviderProfile = env + .storage() + .persistent() + .get(&DataKey::Provider(record.provider.clone())) + .ok_or(ContractError::ProviderNotFound)?; + + // Update the running totals and average (scaled by 100). + profile.rating_sum = profile.rating_sum.saturating_add(rating); + profile.total_reviews = profile.total_reviews.saturating_add(1); + profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews; + + env.storage() + .persistent() + .set(&DataKey::Provider(record.provider.clone()), &profile); + + // Mark this record as rated and persist the review entry. + env.storage() + .persistent() + .set(&DataKey::RatedRecord(record_id), &true); + + let review = Review { + record_id, + provider: record.provider.clone(), + rater: record.owner.clone(), + rating, + comment, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::Review(record_id), &review); + + // Emit the `provider_rated` event with the rating value and record id. + let topic = Symbol::new(env, "provider_rated"); + env.events() + .publish((topic, record.provider), (rating, record_id)); + + Ok(()) +} - // No state change occurred. - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 0); - assert_eq!(rating.average_rating, 0); +/// Returns the current `{ average_rating, total_reviews }` for a provider. +/// Unknown providers return zero values rather than an error so callers can +/// use this as a cheap query. +pub fn get_provider_rating(env: &Env, provider_address: Address) -> ProviderRating { + match env + .storage() + .persistent() + .get::<_, ProviderProfile>(&DataKey::Provider(provider_address)) + { + Some(p) => ProviderRating { + average_rating: p.average_rating, + total_reviews: p.total_reviews, + }, + None => ProviderRating { + average_rating: 0, + total_reviews: 0, + }, } +} - #[test] - fn rate_provider_rejects_rating_above_five() { - let fx = setup(); - - let result = fx - .client - .try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, "")); - assert_eq!(result, Err(Ok(ContractError::InvalidRating))); - - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 0); - assert_eq!(rating.average_rating, 0); - } +/// Fetch the persisted review for a record, if any. +pub fn get_review(env: &Env, record_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Review(record_id)) } diff --git a/contracts/opsce/src/tests_kyc.rs b/contracts/opsce/src/tests_kyc.rs new file mode 100644 index 00000000..3aee9830 --- /dev/null +++ b/contracts/opsce/src/tests_kyc.rs @@ -0,0 +1,117 @@ +#![cfg(test)] +extern crate std; + +use crate::kyc_verification::KycStatus; +use crate::{OpsceContract, OpsceContractClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, Env}; + +struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + oracle: Address, + user: Address, +} + +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + client.add_kyc_oracle(&oracle); + + Fixture { + env, + client, + oracle, + user, + } +} + +#[test] +fn oracle_can_submit_approval_and_status_is_visible() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 1_000; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Status reflects the approval with the stored expiry. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Approved); + assert_eq!(info.expiry, expiry); + + // require_kyc is now satisfied. + fx.client.require_kyc(&fx.user); +} + +#[test] +fn approval_expires_after_stored_timestamp() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 100; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Move ledger time past the expiry. + fx.env.ledger().with_mut(|l| { + l.timestamp = expiry + 1; + }); + + // get_kyc_status surfaces Expired. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Expired); + assert_eq!(info.expiry, expiry); + + // require_kyc rejects an expired approval. + let result = fx.client.try_require_kyc(&fx.user); + assert!(result.is_err(), "expired KYC should be rejected"); +} + +#[test] +fn non_oracle_caller_is_rejected() { + let fx = setup(); + let imposter = Address::generate(&fx.env); + let expiry = fx.env.ledger().timestamp() + 1_000; + + let result = fx.client.try_submit_kyc_result( + &imposter, + &fx.user, + &KycStatus::Approved, + &expiry, + ); + assert!(result.is_err(), "non-oracle submission must be rejected"); + + // No record was written, so require_kyc still rejects. + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); + + // And status remains Pending. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Pending); + assert_eq!(info.expiry, 0); +} + +#[test] +fn rejected_status_blocks_require_kyc() { + let fx = setup(); + let expiry = fx.env.ledger().timestamp() + 500; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Rejected, &expiry); + + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Rejected); + + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); +} diff --git a/contracts/opsce/src/tests_provider_rating.rs b/contracts/opsce/src/tests_provider_rating.rs new file mode 100644 index 00000000..3e516cb6 --- /dev/null +++ b/contracts/opsce/src/tests_provider_rating.rs @@ -0,0 +1,126 @@ +#![cfg(test)] +extern crate std; + +use crate::{ContractError, OpsceContract, OpsceContractClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String}; + +struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + owner: Address, + provider: Address, + record_id: u64, +} + +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let provider = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin); + client.register_provider(&provider); + + let record_id: u64 = 1; + client.record_completed_maintenance(&record_id, &101u64, &owner, &provider); + + Fixture { + env, + client, + owner, + provider, + record_id, + } +} + +#[test] +fn rate_provider_valid_rating_updates_running_average() { + let fx = setup(); + + // First rating: 5 stars. + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100 + + // Second rating on a different record: 4 stars => running avg 4.5. + let record_id2: u64 = 2; + fx.client + .record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider); + fx.client + .rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 2); + assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450 +} + +#[test] +fn rate_provider_duplicate_returns_already_rated() { + let fx = setup(); + + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + // Re-rating the same record must fail with AlreadyRated. + let result = fx.client.try_rate_provider( + &fx.record_id, + &4u32, + &String::from_str(&fx.env, "Again"), + ); + assert!(result.is_err(), "duplicate rating should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::AlreadyRated as u32); + assert_eq!(err, expected); + } + + // The provider's average must remain at the first rating only. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); +} + +#[test] +fn rate_provider_rejects_rating_below_one() { + let fx = setup(); + + let result = + fx.client + .try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, "")); + assert!(result.is_err(), "rating 0 should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::InvalidRating as u32); + assert_eq!(err, expected); + } + + // No state change occurred. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); +} + +#[test] +fn rate_provider_rejects_rating_above_five() { + let fx = setup(); + + let result = + fx.client + .try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, "")); + assert!(result.is_err(), "rating 6 should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::InvalidRating as u32); + assert_eq!(err, expected); + } + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); +}