diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index d697505f..309c0068 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -872,6 +872,13 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opsce" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "p256" version = "0.13.2" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 62be99f9..1e762782 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contrib", "multisig-wallet", "multisig_transfer", + "opsce", ] [workspace.dependencies] diff --git a/contracts/opsce/Cargo.toml b/contracts/opsce/Cargo.toml new file mode 100644 index 00000000..c28b16dd --- /dev/null +++ b/contracts/opsce/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "opsce" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/opsce/src/error.rs b/contracts/opsce/src/error.rs new file mode 100644 index 00000000..3838c199 --- /dev/null +++ b/contracts/opsce/src/error.rs @@ -0,0 +1,38 @@ +//! Shared contract error type for the opsce crate. +//! +//! All modules in the crate (provider rating, KYC verification, ...) report +//! failures through this single enum so callers and tests have a uniform +//! error surface. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + // -- Lifecycle -- + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + + // -- Provider rating -- + /// Rating is outside the allowed 1..=5 range. + InvalidRating = 4, + /// The maintenance record has already been rated. + AlreadyRated = 5, + /// Maintenance record id was not found. + RecordNotFound = 6, + /// Maintenance record exists but is not yet marked complete. + RecordNotComplete = 7, + /// Provider profile is not registered. + ProviderNotFound = 8, + + // -- KYC verification -- + /// The caller is not registered as a KYC oracle. + NotOracle = 20, + /// `submit_kyc_result` was called with a status that is not Approved or + /// Rejected. + InvalidKycStatus = 21, + /// The address has no Approved (or non-expired) KYC record. + KycNotApproved = 22, +} 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/lib.rs b/contracts/opsce/src/lib.rs new file mode 100644 index 00000000..9f41a5d8 --- /dev/null +++ b/contracts/opsce/src/lib.rs @@ -0,0 +1,97 @@ +#![no_std] +#![allow(clippy::too_many_arguments)] + +pub mod error; +pub mod kyc_verification; +pub mod provider_rating; + +pub use error::ContractError; +pub use kyc_verification::{KycDataKey, KycRecord, KycStatus, KycStatusInfo}; +pub use provider_rating::{ + DataKey, MaintenanceRecord, ProviderProfile, ProviderRating, Review, +}; + +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +#[contract] +pub struct OpsceContract; + +#[contractimpl] +impl OpsceContract { + // ---- Lifecycle ---- + + pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { + provider_rating::init(&env, admin) + } + + // ---- Provider rating ---- + + pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> { + provider_rating::register_provider(&env, provider) + } + + pub fn record_completed_maintenance( + env: Env, + record_id: u64, + asset_id: u64, + owner: Address, + provider: Address, + ) -> Result<(), ContractError> { + provider_rating::record_completed_maintenance(&env, record_id, asset_id, owner, provider) + } + + pub fn rate_provider( + env: Env, + record_id: u64, + rating: u32, + comment: String, + ) -> Result<(), ContractError> { + provider_rating::rate_provider(&env, record_id, rating, comment) + } + + pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating { + provider_rating::get_provider_rating(&env, provider_address) + } + + pub fn get_review(env: Env, record_id: u64) -> Option { + provider_rating::get_review(&env, record_id) + } + + // ---- KYC verification ---- + + pub fn add_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + kyc_verification::add_kyc_oracle(&env, oracle) + } + + pub fn remove_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + kyc_verification::remove_kyc_oracle(&env, oracle) + } + + pub fn is_kyc_oracle(env: Env, oracle: Address) -> bool { + kyc_verification::is_kyc_oracle(&env, oracle) + } + + pub fn submit_kyc_result( + env: Env, + oracle: Address, + address: Address, + status: KycStatus, + expiry: u64, + ) -> Result<(), ContractError> { + kyc_verification::submit_kyc_result(&env, oracle, address, status, expiry) + } + + pub fn require_kyc(env: Env, address: Address) -> Result<(), ContractError> { + kyc_verification::require_kyc(&env, address) + } + + pub fn get_kyc_status(env: Env, address: Address) -> KycStatusInfo { + kyc_verification::get_kyc_status(&env, address) + } +} + +#[cfg(test)] +mod tests_provider_rating; + +#[cfg(test)] +mod tests_kyc; diff --git a/contracts/opsce/src/provider_rating.rs b/contracts/opsce/src/provider_rating.rs new file mode 100644 index 00000000..86114bce --- /dev/null +++ b/contracts/opsce/src/provider_rating.rs @@ -0,0 +1,251 @@ +//! Provider rating module +//! +//! Implements a 1-5 star rating system that lets an asset owner rate a +//! maintenance provider after a maintenance record is marked complete. +//! +//! Acceptance criteria covered: +//! - `rate_provider(env, record_id, rating, comment)` — caller must be the +//! asset owner; rating must be 1..=5 +//! - Maintains a running average rating stored on the `ProviderProfile` +//! (scaled by 100, e.g. 4.50 stars => 450) +//! - A given record may be rated only once. Re-rating returns +//! `Err(ContractError::AlreadyRated)`. +//! - `get_provider_rating(env, provider_address)` returns +//! `{ average_rating, total_reviews }` +//! - Emits a `provider_rated` event carrying the rating value and record id + +use soroban_sdk::{contracttype, Address, Env, String, Symbol}; + +pub use crate::error::ContractError; + +/// Provider profile with cumulative rating fields. +/// +/// `average_rating` is stored scaled by 100 so a rating of 4.5 stars is +/// represented as 450. This avoids floating point in the WASM contract while +/// preserving two decimal places of precision. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderProfile { + pub address: Address, + pub total_reviews: u32, + pub rating_sum: u32, + pub average_rating: u32, +} + +/// Lightweight maintenance record needed for the rating workflow. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecord { + pub record_id: u64, + pub asset_id: u64, + pub provider: Address, + pub owner: Address, + pub completed: bool, +} + +/// Public read model returned by `get_provider_rating`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderRating { + /// Average rating scaled by 100 (e.g. 450 = 4.50 stars). Zero when no + /// reviews have been submitted yet. + pub average_rating: u32, + pub total_reviews: u32, +} + +/// Persisted rating entry for a single maintenance record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Review { + pub record_id: u64, + pub provider: Address, + pub rater: Address, + pub rating: u32, + pub comment: String, + pub timestamp: u64, +} + +#[contracttype] +pub enum DataKey { + Admin, + Provider(Address), + Record(u64), + /// Marker key written when a record has been rated. + RatedRecord(u64), + /// The persisted review for a record id. + Review(u64), +} + +/// Read the admin address registered through [`init`]. +pub fn read_admin(env: &Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized) +} + +/// 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 = 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(()) +} + +/// 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); + } + + 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, + }, + } +} + +/// 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); +}