Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ mod test_admin;
mod test_admin_simple;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_admin_standalone;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_admin_two_step;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_audit;
Expand All @@ -86,7 +86,7 @@ mod test_backup;
mod test_backup_safety;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_backup_restore_reindex;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_event_completeness;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_bid_ttl;
Expand All @@ -106,6 +106,8 @@ mod test_expired_bids_cleanup;
mod test_freshness;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_init;
#[cfg(test)]
mod test_config_bounds_matrix;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_invariant_self_check;
#[cfg(all(test, feature = "legacy-tests"))]
Expand Down Expand Up @@ -147,7 +149,7 @@ mod test_analytics_consistency;
mod test_bid_ranking;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_events;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_pause_reads_available;
#[cfg(all(test, feature = "fuzz-tests"))]
mod test_fuzz_invoice_metadata;
Expand All @@ -166,11 +168,11 @@ mod test_investment_transitions;
mod test_invoice_metadata;
#[cfg(test)]
mod test_invoice_search_ranking;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_rebuild_indexes;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_max_invoices_per_business;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_category_breakdown;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_diagnostics;
Expand Down
206 changes: 206 additions & 0 deletions quicklendx-contracts/src/test_config_bounds_matrix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//! Bounds-enforcement matrix for admin protocol and fee configuration.
//!
//! These tests pin the exact accepted boundary values and first rejected values
//! for `set_fee_config` and `set_protocol_config`, and assert failed admin or
//! validation checks leave the readable on-chain config unchanged.

use super::*;
use crate::admin::AdminStorage;
use crate::errors::QuickLendXError;
use soroban_sdk::{testutils::Address as _, Address, Env};

const VALID_MIN_INVOICE_AMOUNT: i128 = 1_000_000;
const VALID_MAX_DUE_DATE_DAYS: u64 = 365;
const VALID_GRACE_PERIOD_SECONDS: u64 = 604_800;
const MAX_FEE_BPS: u32 = 1_000;
const MAX_DUE_DATE_DAYS: u64 = 730;
const MAX_GRACE_PERIOD_SECONDS: u64 = 2_592_000;

fn setup_initialized() -> (Env, QuickLendXContractClient<'static>, Address) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(QuickLendXContract, ());
let client = QuickLendXContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);

env.as_contract(&contract_id, || {
AdminStorage::initialize(&env, &admin).unwrap();
});
client.set_fee_config(&admin, &200);
client.set_protocol_config(
&admin,
&VALID_MIN_INVOICE_AMOUNT,
&VALID_MAX_DUE_DATE_DAYS,
&VALID_GRACE_PERIOD_SECONDS,
);

(env, client, admin)
}

fn assert_contract_err<T: core::fmt::Debug, C: core::fmt::Debug, E: core::fmt::Debug>(
result: Result<Result<T, C>, Result<QuickLendXError, E>>,
expected: QuickLendXError,
) {
match result {
Err(Ok(err)) => assert_eq!(err, expected),
other => panic!("expected contract error {:?}, got {:?}", expected, other),
}
}

#[test]
fn test_set_fee_config_bounds_matrix() {
let (_env, client, admin) = setup_initialized();

for accepted_fee_bps in [0, MAX_FEE_BPS] {
assert!(
client.try_set_fee_config(&admin, &accepted_fee_bps).is_ok(),
"fee_bps={} must be accepted",
accepted_fee_bps
);
assert_eq!(client.get_fee_bps(), accepted_fee_bps);
}

assert_contract_err(
client.try_set_fee_config(&admin, &(MAX_FEE_BPS + 1)),
QuickLendXError::InvalidFeeBasisPoints,
);
assert_eq!(client.get_fee_bps(), MAX_FEE_BPS);
}

#[test]
fn test_set_protocol_config_min_invoice_amount_bounds_matrix() {
let (_env, client, admin) = setup_initialized();

assert!(
client
.try_set_protocol_config(
&admin,
&1i128,
&VALID_MAX_DUE_DATE_DAYS,
&VALID_GRACE_PERIOD_SECONDS,
)
.is_ok(),
"min_invoice_amount=1 must be accepted"
);
assert_eq!(client.get_min_invoice_amount(), 1);

for rejected_min_invoice_amount in [0, -1] {
assert_contract_err(
client.try_set_protocol_config(
&admin,
&rejected_min_invoice_amount,
&VALID_MAX_DUE_DATE_DAYS,
&VALID_GRACE_PERIOD_SECONDS,
),
QuickLendXError::InvalidAmount,
);
assert_eq!(client.get_min_invoice_amount(), 1);
}
}

#[test]
fn test_set_protocol_config_due_date_bounds_matrix() {
let (_env, client, admin) = setup_initialized();

for accepted_max_due_date_days in [1, MAX_DUE_DATE_DAYS] {
assert!(
client
.try_set_protocol_config(
&admin,
&VALID_MIN_INVOICE_AMOUNT,
&accepted_max_due_date_days,
&VALID_GRACE_PERIOD_SECONDS,
)
.is_ok(),
"max_due_date_days={} must be accepted",
accepted_max_due_date_days
);
assert_eq!(client.get_max_due_date_days(), accepted_max_due_date_days);
assert_eq!(
client.get_grace_period_seconds(),
VALID_GRACE_PERIOD_SECONDS
);
}

for rejected_max_due_date_days in [0, MAX_DUE_DATE_DAYS + 1] {
assert_contract_err(
client.try_set_protocol_config(
&admin,
&VALID_MIN_INVOICE_AMOUNT,
&rejected_max_due_date_days,
&VALID_GRACE_PERIOD_SECONDS,
),
QuickLendXError::InvoiceDueDateInvalid,
);
assert_eq!(client.get_max_due_date_days(), MAX_DUE_DATE_DAYS);
}
}

#[test]
fn test_set_protocol_config_grace_period_bounds_matrix() {
let (_env, client, admin) = setup_initialized();

for accepted_grace_period_seconds in [0, MAX_GRACE_PERIOD_SECONDS] {
assert!(
client
.try_set_protocol_config(
&admin,
&VALID_MIN_INVOICE_AMOUNT,
&VALID_MAX_DUE_DATE_DAYS,
&accepted_grace_period_seconds,
)
.is_ok(),
"grace_period_seconds={} must be accepted",
accepted_grace_period_seconds
);
assert_eq!(client.get_max_due_date_days(), VALID_MAX_DUE_DATE_DAYS);
assert_eq!(
client.get_grace_period_seconds(),
accepted_grace_period_seconds
);
}

assert_contract_err(
client.try_set_protocol_config(
&admin,
&VALID_MIN_INVOICE_AMOUNT,
&VALID_MAX_DUE_DATE_DAYS,
&(MAX_GRACE_PERIOD_SECONDS + 1),
),
QuickLendXError::InvalidTimestamp,
);
assert_eq!(client.get_grace_period_seconds(), MAX_GRACE_PERIOD_SECONDS);
}

#[test]
fn test_config_bounds_reject_non_admin_without_mutation() {
let (env, client, _admin) = setup_initialized();
let non_admin = Address::generate(&env);
let fee_before = client.get_fee_bps();
let min_invoice_amount_before = client.get_min_invoice_amount();
let max_due_date_days_before = client.get_max_due_date_days();
let grace_period_seconds_before = client.get_grace_period_seconds();

assert_contract_err(
client.try_set_fee_config(&non_admin, &MAX_FEE_BPS),
QuickLendXError::NotAdmin,
);
assert_eq!(client.get_fee_bps(), fee_before);

assert_contract_err(
client.try_set_protocol_config(
&non_admin,
&1i128,
&MAX_DUE_DATE_DAYS,
&MAX_GRACE_PERIOD_SECONDS,
),
QuickLendXError::NotAdmin,
);

assert_eq!(client.get_min_invoice_amount(), min_invoice_amount_before);
assert_eq!(client.get_max_due_date_days(), max_due_date_days_before);
assert_eq!(
client.get_grace_period_seconds(),
grace_period_seconds_before
);
}
61 changes: 34 additions & 27 deletions quicklendx-contracts/src/test_invoice_search_ranking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
mod test_invoice_search_ranking {
use crate::invoice_search::InvoiceSearch;
use crate::storage::InvoiceStorage;
use crate::types::{Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult, Dispute};
use crate::types::{
Dispute, Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult,
};
use crate::QuickLendXContract;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{Address, BytesN, Env, String, Vec};

Expand All @@ -14,6 +17,11 @@ mod test_invoice_search_ranking {
env
}

fn with_contract<T>(env: &Env, f: impl FnOnce() -> T) -> T {
let contract_id = env.register(QuickLendXContract, ());
env.as_contract(&contract_id, f)
}

fn create_test_invoice(
env: &Env,
business: &Address,
Expand Down Expand Up @@ -93,6 +101,7 @@ mod test_invoice_search_ranking {
exact_id_bytes[1] = 0x62;
exact_id_bytes[2] = 0x63;
let exact_id = BytesN::from_array(&env, &exact_id_bytes);
let exact_id_query = "6162630000000000000000000000000000000000000000000000000000000000";

let dispute = Dispute {
created_by: business.clone(),
Expand Down Expand Up @@ -138,29 +147,24 @@ mod test_invoice_search_ranking {
let invoice_partial = create_test_invoice(
&env,
&business,
"this has abc in description",
"this has 6162630000000000000000000000000000000000000000000000000000000000 in description",
None,
None,
1000,
);

// Invoice 3: No match
let invoice_other = create_test_invoice(
&env,
&business,
"unrelated search target",
None,
None,
1000,
);

InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceStorage::store_invoice(&env, &invoice_other);
let invoice_other =
create_test_invoice(&env, &business, "unrelated search target", None, None, 1000);

// Query "abc" (corresponds to exact_id hex string)
let query = String::from_str(&env, "6162630000000000000000000000000000000000000000000000000000000000");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let query = String::from_str(&env, exact_id_query);
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceStorage::store_invoice(&env, &invoice_other);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

// Should return 2 results: exact match first, then partial match. Other should be filtered out.
assert_eq!(results.len(), 2);
Expand All @@ -185,12 +189,13 @@ mod test_invoice_search_ranking {
let invoice_mid = create_test_invoice(&env, &business, "abc mid", None, None, 2000);
let invoice_new = create_test_invoice(&env, &business, "abc new", None, None, 3000);

InvoiceStorage::store_invoice(&env, &invoice_old);
InvoiceStorage::store_invoice(&env, &invoice_mid);
InvoiceStorage::store_invoice(&env, &invoice_new);

let query = String::from_str(&env, "abc");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_old);
InvoiceStorage::store_invoice(&env, &invoice_mid);
InvoiceStorage::store_invoice(&env, &invoice_new);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

assert_eq!(results.len(), 3);

Expand All @@ -216,6 +221,7 @@ mod test_invoice_search_ranking {
exact_id_bytes[1] = 0x79;
exact_id_bytes[2] = 0x7a;
let exact_id = BytesN::from_array(&env, &exact_id_bytes);
let exact_id_query = "78797a0000000000000000000000000000000000000000000000000000000000";

let dispute = Dispute {
created_by: business.clone(),
Expand Down Expand Up @@ -261,18 +267,19 @@ mod test_invoice_search_ranking {
let invoice_partial = create_test_invoice(
&env,
&business,
"this has 78797a in description",
"this has 78797a0000000000000000000000000000000000000000000000000000000000 in description",
None,
None,
5000,
);

InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);

// Query by the hex string of exact_id
let query = String::from_str(&env, "78797a0000000000000000000000000000000000000000000000000000000000");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let query = String::from_str(&env, exact_id_query);
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

assert_eq!(results.len(), 2);
// Exact ID must be first, despite having a lower created_at timestamp
Expand Down
Loading