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);
+}