From 757fe7eaa8ad10fe5f15d5a4e1ddb970e2c42970 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 13:31:28 +0800 Subject: [PATCH 1/7] test(bidding): add cancel-vs-accept interleaving regression --- quicklendx-contracts/src/lib.rs | 2 + .../src/test_bid_cancel_accept_race.rs | 288 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 quicklendx-contracts/src/test_bid_cancel_accept_race.rs diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 3e9f910d..d383ca30 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -113,6 +113,8 @@ mod test_investment_consistency; #[cfg(all(test, feature = "legacy-tests"))] mod test_accept_bid_race; #[cfg(all(test, feature = "legacy-tests"))] +mod test_bid_cancel_accept_race; +#[cfg(all(test, feature = "legacy-tests"))] mod test_accept_bid_instruction_budget; // #[cfg(test)] // mod test_investment_queries; diff --git a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs new file mode 100644 index 00000000..1f11bcc7 --- /dev/null +++ b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs @@ -0,0 +1,288 @@ +//! Cancel-vs-accept interleaving regression for a single bid. +//! +//! Soroban executes transactions serially, but an investor cancelling a bid and +//! a business accepting that same bid can be submitted in the same logical +//! window. These tests model both possible ledger orderings and assert that the +//! second transition is rejected without leaving split bid, invoice, escrow, or +//! investment state. + +use super::*; +use crate::errors::QuickLendXError; +use crate::investment::InvestmentStatus; +use crate::invoice::{InvoiceCategory, InvoiceStatus}; +use crate::payments::EscrowStatus; +use crate::types::BidStatus; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, String, Vec, +}; + +struct CancelAcceptFixture { + env: Env, + client: QuickLendXContractClient<'static>, + contract_id: Address, + invoice_id: BytesN<32>, + bid_id: BytesN<32>, + investor: Address, +} + +fn setup() -> (Env, QuickLendXContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.set_admin(&admin); + (env, client, admin) +} + +fn setup_token( + env: &Env, + contract_id: &Address, + business: &Address, + investor: &Address, + business_balance: i128, + investor_balance: i128, +) -> Address { + let token_admin = Address::generate(env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sac = token::StellarAssetClient::new(env, ¤cy); + let tok = token::Client::new(env, ¤cy); + + sac.mint(business, &business_balance); + sac.mint(investor, &investor_balance); + sac.mint(contract_id, &1i128); + + let exp = env.ledger().sequence() + 100_000; + tok.approve(business, contract_id, &(business_balance * 4), &exp); + tok.approve(investor, contract_id, &(investor_balance * 4), &exp); + + currency +} + +fn verified_business(env: &Env, client: &QuickLendXContractClient, admin: &Address) -> Address { + let business = Address::generate(env); + client.submit_kyc_application(&business, &String::from_str(env, "Business KYC")); + client.verify_business(admin, &business); + business +} + +fn verified_investor( + env: &Env, + client: &QuickLendXContractClient, + _admin: &Address, + limit: i128, +) -> Address { + let investor = Address::generate(env); + client.submit_investor_kyc(&investor, &String::from_str(env, "Investor KYC")); + client.verify_investor(&investor, &limit); + investor +} + +fn build_cancel_accept_fixture() -> CancelAcceptFixture { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + let business = verified_business(&env, &client, &admin); + let investor = verified_investor(&env, &client, &admin, 50_000); + let currency = setup_token(&env, &contract_id, &business, &investor, 20_000, 20_000); + client.add_currency(&admin, ¤cy); + + let invoice_amount = 10_000i128; + let bid_amount = 9_000i128; + let due_date = env.ledger().timestamp() + 86_400; + let invoice_id = client.upload_invoice( + &business, + &invoice_amount, + ¤cy, + &due_date, + &String::from_str(&env, "Cancel accept race invoice"), + &InvoiceCategory::Services, + &Vec::new(&env), + ); + client.verify_invoice(&invoice_id); + + let bid_id = client.place_bid(&investor, &invoice_id, &bid_amount, &invoice_amount); + + CancelAcceptFixture { + env, + client, + contract_id, + invoice_id, + bid_id, + investor, + } +} + +fn assert_no_funding_state(client: &QuickLendXContractClient, invoice_id: &BytesN<32>) { + let invoice = client.get_invoice(invoice_id); + assert_eq!( + invoice.status, + InvoiceStatus::Verified, + "cancelled bid must leave invoice available but unfunded" + ); + assert_eq!(invoice.funded_amount, 0); + assert!(invoice.funded_at.is_none()); + assert!(invoice.investor.is_none()); + assert!( + client.get_escrow_details(invoice_id).is_err(), + "cancelled bid must not create escrow" + ); + assert!( + client.get_invoice_investment(invoice_id).is_err(), + "cancelled bid must not create investment" + ); +} + +fn assert_funded_state( + client: &QuickLendXContractClient, + invoice_id: &BytesN<32>, + bid_id: &BytesN<32>, + investor: &Address, +) { + let invoice = client.get_invoice(invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Funded); + assert_eq!(invoice.funded_amount, 9_000); + assert_eq!(invoice.investor, Some(investor.clone())); + assert!(invoice.funded_at.is_some()); + + let bid = client.get_bid(bid_id).expect("bid must exist"); + assert_eq!(bid.status, BidStatus::Accepted); + + let escrow = client + .get_escrow_details(invoice_id) + .expect("escrow must exist after accepted bid"); + assert_eq!(escrow.status, EscrowStatus::Held); + assert_eq!(escrow.amount, 9_000); + assert_eq!(escrow.investor, *investor); + + let investment = client + .get_invoice_investment(invoice_id) + .expect("investment must exist after accepted bid"); + assert_eq!(investment.status, InvestmentStatus::Active); + assert_eq!(investment.invoice_id, *invoice_id); + assert_eq!(investment.investor, *investor); + assert_eq!(investment.amount, 9_000); +} + +fn assert_invoice_count_invariant(client: &QuickLendXContractClient) { + let total = client.get_total_invoice_count(); + let sum = client.get_invoice_count_by_status(&InvoiceStatus::Pending) + + client.get_invoice_count_by_status(&InvoiceStatus::Verified) + + client.get_invoice_count_by_status(&InvoiceStatus::Funded) + + client.get_invoice_count_by_status(&InvoiceStatus::Paid) + + client.get_invoice_count_by_status(&InvoiceStatus::Defaulted) + + client.get_invoice_count_by_status(&InvoiceStatus::Cancelled) + + client.get_invoice_count_by_status(&InvoiceStatus::Refunded); + assert_eq!(total, sum, "invoice status indexes must remain balanced"); +} + +/// Race ordering: the investor cancellation is ordered before the business +/// acceptance. The cancelled bid must not be selected by ranking or funded by a +/// later accept attempt. +#[test] +fn test_cancel_then_accept_same_bid_rejects_accept_and_leaves_no_partial_state() { + let fixture = build_cancel_accept_fixture(); + + assert_eq!( + fixture + .client + .get_bid(&fixture.bid_id) + .expect("bid must exist") + .status, + BidStatus::Placed + ); + + assert!( + fixture.client.cancel_bid(&fixture.bid_id), + "first cancel must transition Placed -> Cancelled" + ); + + let bid = fixture + .client + .get_bid(&fixture.bid_id) + .expect("bid must remain stored"); + assert_eq!(bid.status, BidStatus::Cancelled); + assert!( + fixture.client.get_best_bid(&fixture.invoice_id).is_none(), + "cancelled bid must not be returned by get_best_bid" + ); + + let accept_after_cancel = fixture + .client + .try_accept_bid_and_fund(&fixture.invoice_id, &fixture.bid_id); + let err = accept_after_cancel + .expect_err("accepting a cancelled bid must fail") + .expect("contract error must decode"); + assert_eq!(err, QuickLendXError::InvalidStatus); + + let bid_after = fixture + .client + .get_bid(&fixture.bid_id) + .expect("bid must remain stored"); + assert_eq!( + bid_after.status, + BidStatus::Cancelled, + "failed accept must not resurrect or fund a cancelled bid" + ); + assert_no_funding_state(&fixture.client, &fixture.invoice_id); + assert_invoice_count_invariant(&fixture.client); + + let token_client = token::Client::new( + &fixture.env, + &fixture.client.get_invoice(&fixture.invoice_id).currency, + ); + assert_eq!( + token_client.balance(&fixture.contract_id), + 1, + "failed accept must not transfer investor funds into escrow" + ); + assert_eq!(token_client.balance(&fixture.investor), 20_000); +} + +/// Race ordering: the business acceptance is ordered before the investor +/// cancellation. Once the bid is Accepted, cancellation must be a false no-op +/// and the funded invoice, escrow, and investment must remain mutually +/// consistent. +#[test] +fn test_accept_then_cancel_same_bid_rejects_cancel_and_preserves_funded_state() { + let fixture = build_cancel_accept_fixture(); + + let accept = fixture + .client + .try_accept_bid_and_fund(&fixture.invoice_id, &fixture.bid_id); + assert!( + accept.is_ok(), + "first accept must succeed before any cancellation; got {accept:?}" + ); + + assert!( + !fixture.client.cancel_bid(&fixture.bid_id), + "cancel_bid must return false once the bid is Accepted" + ); + + assert_funded_state( + &fixture.client, + &fixture.invoice_id, + &fixture.bid_id, + &fixture.investor, + ); + assert!( + fixture.client.get_best_bid(&fixture.invoice_id).is_none(), + "accepted bid must not remain selectable as a Placed best bid" + ); + assert_invoice_count_invariant(&fixture.client); + + let token_client = token::Client::new( + &fixture.env, + &fixture.client.get_invoice(&fixture.invoice_id).currency, + ); + assert_eq!( + token_client.balance(&fixture.contract_id), + 9_001, + "contract balance must contain only the seeded token plus accepted bid amount" + ); + assert_eq!(token_client.balance(&fixture.investor), 11_000); +} From d517056ae39457a4cd4099b120ed5c347117f06c Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 14:03:23 +0800 Subject: [PATCH 2/7] test: run cancel accept race in default suite --- quicklendx-contracts/src/lib.rs | 10 +++++----- .../src/test_bid_cancel_accept_race.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index d383ca30..894b6413 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -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; @@ -112,7 +112,7 @@ mod test_invariant_self_check; mod test_investment_consistency; #[cfg(all(test, feature = "legacy-tests"))] mod test_accept_bid_race; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_bid_cancel_accept_race; #[cfg(all(test, feature = "legacy-tests"))] mod test_accept_bid_instruction_budget; @@ -149,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; @@ -168,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; diff --git a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs index 1f11bcc7..d4ebe013 100644 --- a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs +++ b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs @@ -29,7 +29,7 @@ struct CancelAcceptFixture { fn setup() -> (Env, QuickLendXContractClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); - env.ledger().set_timestamp(1_000); + env.ledger().with_mut(|ledger| ledger.timestamp = 1_000); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -127,11 +127,11 @@ fn assert_no_funding_state(client: &QuickLendXContractClient, invoice_id: &Bytes assert!(invoice.funded_at.is_none()); assert!(invoice.investor.is_none()); assert!( - client.get_escrow_details(invoice_id).is_err(), + client.try_get_escrow_details(invoice_id).is_err(), "cancelled bid must not create escrow" ); assert!( - client.get_invoice_investment(invoice_id).is_err(), + client.try_get_invoice_investment(invoice_id).is_err(), "cancelled bid must not create investment" ); } From 6a8044d46102d02b34dd07ac9134ef085e093463 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 14:46:02 +0800 Subject: [PATCH 3/7] test: fix cancel accept coverage suite --- quicklendx-contracts/src/lib.rs | 2 +- .../src/test_bid_cancel_accept_race.rs | 8 +-- .../src/test_invoice_search_ranking.rs | 63 +++++++++++-------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 894b6413..6477c404 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -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; diff --git a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs index d4ebe013..66ca6100 100644 --- a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs +++ b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs @@ -151,16 +151,12 @@ fn assert_funded_state( let bid = client.get_bid(bid_id).expect("bid must exist"); assert_eq!(bid.status, BidStatus::Accepted); - let escrow = client - .get_escrow_details(invoice_id) - .expect("escrow must exist after accepted bid"); + let escrow = client.get_escrow_details(invoice_id); assert_eq!(escrow.status, EscrowStatus::Held); assert_eq!(escrow.amount, 9_000); assert_eq!(escrow.investor, *investor); - let investment = client - .get_invoice_investment(invoice_id) - .expect("investment must exist after accepted bid"); + let investment = client.get_invoice_investment(invoice_id); assert_eq!(investment.status, InvestmentStatus::Active); assert_eq!(investment.invoice_id, *invoice_id); assert_eq!(investment.investor, *investor); diff --git a/quicklendx-contracts/src/test_invoice_search_ranking.rs b/quicklendx-contracts/src/test_invoice_search_ranking.rs index 32e4f556..9834d819 100644 --- a/quicklendx-contracts/src/test_invoice_search_ranking.rs +++ b/quicklendx-contracts/src/test_invoice_search_ranking.rs @@ -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}; @@ -14,6 +17,11 @@ mod test_invoice_search_ranking { env } + fn with_contract(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, @@ -93,6 +101,8 @@ 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(), @@ -138,29 +148,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); @@ -185,12 +190,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); @@ -216,6 +222,8 @@ 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(), @@ -261,18 +269,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 From 3d9bbaec695d4fdaecf1c62ccf3d1c373e76b043 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 14:59:10 +0800 Subject: [PATCH 4/7] ci: rerun contract checks From 5a1a486adca5a375ca5d127518ed63959580549a Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 15:23:46 +0800 Subject: [PATCH 5/7] test: tighten cancel accept race coverage --- quicklendx-contracts/src/lib.rs | 10 +-- .../src/test_bid_cancel_accept_race.rs | 7 ++- .../src/test_invoice_search_ranking.rs | 63 ++++++++----------- 3 files changed, 36 insertions(+), 44 deletions(-) diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 6477c404..941b2dd3 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -76,7 +76,7 @@ mod test_admin; mod test_admin_simple; #[cfg(all(test, feature = "legacy-tests"))] mod test_admin_standalone; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_admin_two_step; #[cfg(all(test, feature = "legacy-tests"))] mod test_audit; @@ -86,7 +86,7 @@ mod test_backup; mod test_backup_safety; #[cfg(all(test, feature = "legacy-tests"))] mod test_backup_restore_reindex; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_escrow_event_completeness; #[cfg(all(test, feature = "legacy-tests"))] mod test_bid_ttl; @@ -149,7 +149,7 @@ mod test_analytics_consistency; mod test_bid_ranking; #[cfg(all(test, feature = "legacy-tests"))] mod test_events; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_pause_reads_available; #[cfg(all(test, feature = "fuzz-tests"))] mod test_fuzz_invoice_metadata; @@ -168,11 +168,11 @@ mod test_investment_transitions; mod test_invoice_metadata; #[cfg(test)] mod test_invoice_search_ranking; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_rebuild_indexes; #[cfg(all(test, feature = "legacy-tests"))] mod test_max_invoices_per_business; -#[cfg(all(test, feature = "legacy-tests"))] +#[cfg(test)] mod test_category_breakdown; #[cfg(all(test, feature = "legacy-tests"))] mod test_diagnostics; diff --git a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs index 66ca6100..5c9a4ab7 100644 --- a/quicklendx-contracts/src/test_bid_cancel_accept_race.rs +++ b/quicklendx-contracts/src/test_bid_cancel_accept_race.rs @@ -239,9 +239,10 @@ fn test_cancel_then_accept_same_bid_rejects_accept_and_leaves_no_partial_state() } /// Race ordering: the business acceptance is ordered before the investor -/// cancellation. Once the bid is Accepted, cancellation must be a false no-op -/// and the funded invoice, escrow, and investment must remain mutually -/// consistent. +/// cancellation. `cancel_bid` currently exposes non-`Placed` rejection as a +/// deterministic `false` return rather than a `QuickLendXError`; this documents +/// that API gap while still asserting the second transition cannot mutate +/// funded invoice, escrow, or investment state. #[test] fn test_accept_then_cancel_same_bid_rejects_cancel_and_preserves_funded_state() { let fixture = build_cancel_accept_fixture(); diff --git a/quicklendx-contracts/src/test_invoice_search_ranking.rs b/quicklendx-contracts/src/test_invoice_search_ranking.rs index 9834d819..32e4f556 100644 --- a/quicklendx-contracts/src/test_invoice_search_ranking.rs +++ b/quicklendx-contracts/src/test_invoice_search_ranking.rs @@ -4,10 +4,7 @@ mod test_invoice_search_ranking { use crate::invoice_search::InvoiceSearch; use crate::storage::InvoiceStorage; - use crate::types::{ - Dispute, Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult, - }; - use crate::QuickLendXContract; + use crate::types::{Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult, Dispute}; use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, BytesN, Env, String, Vec}; @@ -17,11 +14,6 @@ mod test_invoice_search_ranking { env } - fn with_contract(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, @@ -101,8 +93,6 @@ 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(), @@ -148,24 +138,29 @@ mod test_invoice_search_ranking { let invoice_partial = create_test_invoice( &env, &business, - "this has 6162630000000000000000000000000000000000000000000000000000000000 in description", + "this has abc in description", None, None, 1000, ); // Invoice 3: No match - let invoice_other = - create_test_invoice(&env, &business, "unrelated search target", None, None, 1000); + 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); // Query "abc" (corresponds to exact_id hex string) - 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() - }); + let query = String::from_str(&env, "6162630000000000000000000000000000000000000000000000000000000000"); + let results = 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); @@ -190,13 +185,12 @@ 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 = 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() - }); + let results = InvoiceSearch::search_invoices(&env, query).unwrap(); assert_eq!(results.len(), 3); @@ -222,8 +216,6 @@ 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(), @@ -269,19 +261,18 @@ mod test_invoice_search_ranking { let invoice_partial = create_test_invoice( &env, &business, - "this has 78797a0000000000000000000000000000000000000000000000000000000000 in description", + "this has 78797a 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, 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() - }); + let query = String::from_str(&env, "78797a0000000000000000000000000000000000000000000000000000000000"); + let results = InvoiceSearch::search_invoices(&env, query).unwrap(); assert_eq!(results.len(), 2); // Exact ID must be first, despite having a lower created_at timestamp From 802be162dd9eee2e1ced089e3beeaaafbd3fcb0a Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 15:36:21 +0800 Subject: [PATCH 6/7] test: keep stale coverage modules legacy-gated --- quicklendx-contracts/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 941b2dd3..6477c404 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -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; @@ -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; @@ -149,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; @@ -168,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; From ec26de6600cb41b58d682c49357ec5cd3edafd10 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Fri, 19 Jun 2026 15:47:13 +0800 Subject: [PATCH 7/7] test: run invoice search ranking under contract context --- .../src/test_invoice_search_ranking.rs | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/quicklendx-contracts/src/test_invoice_search_ranking.rs b/quicklendx-contracts/src/test_invoice_search_ranking.rs index 32e4f556..27262650 100644 --- a/quicklendx-contracts/src/test_invoice_search_ranking.rs +++ b/quicklendx-contracts/src/test_invoice_search_ranking.rs @@ -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}; @@ -14,6 +17,11 @@ mod test_invoice_search_ranking { env } + fn with_contract(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, @@ -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(), @@ -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); @@ -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); @@ -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(), @@ -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