Skip to content
Closed
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
68 changes: 59 additions & 9 deletions quicklendx-contracts/src/invoice_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ use crate::errors::QuickLendXError;
use crate::storage::InvoiceStorage;
use crate::types::{Invoice, InvoiceStatus, SearchRank, SearchResult};

/// Maximum number of search results to return
/// Maximum number of search results returned by one invoice-search page.
pub const MAX_SEARCH_RESULTS: u32 = 50;

/// Largest starting offset accepted by paged invoice search.
///
/// The search implementation still scans the indexed invoice IDs, but this
/// bound prevents callers from requesting an unbounded ranked window before the
/// page slice is produced.
pub const MAX_SEARCH_OFFSET: u32 = 1_000;

/// Invoice search functionality with safe query semantics and relevance ranking
pub struct InvoiceSearch;

Expand Down Expand Up @@ -46,14 +53,23 @@ impl InvoiceSearch {
Ok(String::from_str(query.env(), trimmed_str))
}

/// Search invoices with relevance ranking
/// Search invoices with relevance ranking.
///
/// This compatibility wrapper returns the first full page.
pub fn search_invoices(env: &Env, query: String) -> Result<Vec<SearchResult>, QuickLendXError> {
Self::search_invoices_paged(env, query, 0, MAX_SEARCH_RESULTS)
}

/// Search invoices with relevance ranking and bounded paging.
///
/// # Arguments
/// * `env` - Soroban environment
/// * `query` - Search query string (will be sanitized)
/// * `offset` - Number of ranked matches to skip
/// * `limit` - Requested page size, clamped to MAX_SEARCH_RESULTS
///
/// # Returns
/// * `Vec<SearchResult>` - Ranked search results, limited to MAX_SEARCH_RESULTS
/// * `Vec<SearchResult>` - Ranked page, limited to MAX_SEARCH_RESULTS
///
/// # Ranking Logic
/// 1. Exact matches on invoice_id (highest priority)
Expand All @@ -64,8 +80,24 @@ impl InvoiceSearch {
/// - Input sanitization prevents injection attacks
/// - Memory-safe: bounded result set prevents DoS
/// - Case-insensitive search
pub fn search_invoices(env: &Env, query: String) -> Result<Vec<SearchResult>, QuickLendXError> {
pub fn search_invoices_paged(
env: &Env,
query: String,
offset: u32,
limit: u32,
) -> Result<Vec<SearchResult>, QuickLendXError> {
if offset > MAX_SEARCH_OFFSET {
return Err(QuickLendXError::OperationNotAllowed);
}

let sanitized_query = Self::sanitize_query(&query)?;
let capped_limit = limit.min(MAX_SEARCH_RESULTS);

if capped_limit == 0 {
return Ok(Vec::new(env));
}

let retain_limit = offset.saturating_add(capped_limit);

// Get all invoices (in a real implementation, this might be paginated or indexed)
// For now, we'll search through all invoices
Expand All @@ -82,23 +114,41 @@ impl InvoiceSearch {
rank,
created_at: invoice.created_at,
});

if results.len() > retain_limit {
Self::sort_results(&mut results);
let _ = results.pop_back();
}
}
}
}

// Sort by rank (descending) then by created_at (descending)
Self::sort_results(&mut results);

// Limit results
Ok(Self::page_results(env, &results, offset, capped_limit))
}

/// Slice a sorted result set into a bounded page.
fn page_results(
env: &Env,
results: &Vec<SearchResult>,
offset: u32,
limit: u32,
) -> Vec<SearchResult> {
let mut limited_results = Vec::new(env);
let max_results = MAX_SEARCH_RESULTS.min(results.len());
for i in 0..max_results {
if offset >= results.len() {
return limited_results;
}

let end = offset.saturating_add(limit).min(results.len());
for i in offset..end {
if let Some(result) = results.get(i) {
limited_results.push_back(result);
}
}

Ok(limited_results)
limited_results
}

/// Calculate search relevance rank for an invoice
Expand Down Expand Up @@ -231,7 +281,7 @@ impl InvoiceSearch {

/// Sort search results by rank (desc) then created_at (desc)
fn sort_results(results: &mut Vec<SearchResult>) {
// Simple bubble sort for small result sets (MAX_SEARCH_RESULTS = 50)
// Simple bubble sort for the bounded retained search window.
let len = results.len();
for i in 0..len {
for j in 0..(len - i - 1) {
Expand Down
193 changes: 182 additions & 11 deletions quicklendx-contracts/src/test_invoice_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
mod test_invoice_search {
use super::*;
use crate::invoice::{Invoice, InvoiceCategory, InvoiceStatus};
use crate::invoice_search::{InvoiceSearch, MAX_SEARCH_OFFSET, MAX_SEARCH_RESULTS};
use crate::storage::InvoiceStorage;
use crate::types::{SearchRank, SearchResult};
use crate::QuickLendXContract;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{Address, Env, String, Vec};

Expand All @@ -12,6 +15,11 @@ mod test_invoice_search {
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 @@ -69,7 +77,13 @@ mod test_invoice_search {

// Create invoice with known ID
let test_id = "test_invoice_123";
let invoice = create_test_invoice(&env, &business, "consulting services", Some("ABC Corp"), Some(test_id));
let invoice = create_test_invoice(
&env,
&business,
"consulting services",
Some("ABC Corp"),
Some(test_id),
);

// Mock storage - store the invoice
InvoiceStorage::store_invoice(&env, &invoice);
Expand All @@ -89,8 +103,20 @@ mod test_invoice_search {
let business = Address::generate(&env);

// Create invoices
let invoice1 = create_test_invoice(&env, &business, "software development services", Some("XYZ Ltd"), None);
let invoice2 = create_test_invoice(&env, &business, "marketing campaign", Some("ABC Corp"), None);
let invoice1 = create_test_invoice(
&env,
&business,
"software development services",
Some("XYZ Ltd"),
None,
);
let invoice2 = create_test_invoice(
&env,
&business,
"marketing campaign",
Some("ABC Corp"),
None,
);

// Store invoices
InvoiceStorage::store_invoice(&env, &invoice1);
Expand All @@ -111,7 +137,8 @@ mod test_invoice_search {
let business = Address::generate(&env);

// Create invoices
let invoice1 = create_test_invoice(&env, &business, "consulting", Some("ABC Corporation"), None);
let invoice1 =
create_test_invoice(&env, &business, "consulting", Some("ABC Corporation"), None);
let invoice2 = create_test_invoice(&env, &business, "development", Some("XYZ Ltd"), None);

// Store invoices
Expand All @@ -134,10 +161,22 @@ mod test_invoice_search {

// Create invoice with exact ID match
let test_id = "exact_match_123";
let invoice_exact = create_test_invoice(&env, &business, "general services", Some("Test Corp"), Some(test_id));
let invoice_exact = create_test_invoice(
&env,
&business,
"general services",
Some("Test Corp"),
Some(test_id),
);

// Create invoice with partial match
let invoice_partial = create_test_invoice(&env, &business, "software development", Some("Other Corp"), None);
let invoice_partial = create_test_invoice(
&env,
&business,
"software development",
Some("Other Corp"),
None,
);

// Store invoices
InvoiceStorage::store_invoice(&env, &invoice_exact);
Expand Down Expand Up @@ -166,7 +205,13 @@ mod test_invoice_search {
let business = Address::generate(&env);

// Create invoice
let invoice = create_test_invoice(&env, &business, "consulting services", Some("ABC Corp"), None);
let invoice = create_test_invoice(
&env,
&business,
"consulting services",
Some("ABC Corp"),
None,
);
InvoiceStorage::store_invoice(&env, &invoice);

// Search for non-existent term
Expand All @@ -182,7 +227,13 @@ mod test_invoice_search {
let business = Address::generate(&env);

// Create invoice with uppercase
let invoice = create_test_invoice(&env, &business, "SOFTWARE DEVELOPMENT", Some("ABC CORP"), None);
let invoice = create_test_invoice(
&env,
&business,
"SOFTWARE DEVELOPMENT",
Some("ABC CORP"),
None,
);
InvoiceStorage::store_invoice(&env, &invoice);

// Search with lowercase
Expand Down Expand Up @@ -235,16 +286,136 @@ mod test_invoice_search {
assert_eq!(results.len() as u32, MAX_SEARCH_RESULTS);
}

#[test]
fn test_search_invoices_paged_zero_limit_returns_empty() {
let env = setup_test_env();
let business = Address::generate(&env);
let invoice = create_test_invoice(&env, &business, "paged service", None, None);

let query = String::from_str(&env, "paged");
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice);
InvoiceSearch::search_invoices_paged(&env, query, 0, 0).unwrap()
});

assert_eq!(results.len(), 0);
}

#[test]
fn test_search_invoices_paged_clamps_limit_to_cap() {
let env = setup_test_env();
let business = Address::generate(&env);

let query = String::from_str(&env, "paged");
let results = with_contract(&env, || {
for i in 0..60 {
let description = format!("paged service {}", i);
let mut invoice = create_test_invoice(&env, &business, &description, None, None);
invoice.created_at = i;
InvoiceStorage::store_invoice(&env, &invoice);
}

InvoiceSearch::search_invoices_paged(&env, query, 0, u32::MAX).unwrap()
});

assert_eq!(results.len() as u32, MAX_SEARCH_RESULTS);
}

#[test]
fn test_search_invoices_paged_offset_past_end_returns_empty() {
let env = setup_test_env();
let business = Address::generate(&env);
let invoice = create_test_invoice(&env, &business, "paged service", None, None);

let query = String::from_str(&env, "paged");
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice);
InvoiceSearch::search_invoices_paged(&env, query, 2, 10).unwrap()
});

assert_eq!(results.len(), 0);
}

#[test]
fn test_search_invoices_paged_rejects_absurd_offset() {
let env = setup_test_env();
let query = String::from_str(&env, "paged");

let result = InvoiceSearch::search_invoices_paged(&env, query, MAX_SEARCH_OFFSET + 1, 1);

assert_eq!(result, Err(QuickLendXError::OperationNotAllowed));
}

#[test]
fn test_search_invoices_paged_preserves_ranking_across_pages() {
let env = setup_test_env();
let business = Address::generate(&env);

let mut old_invoice = create_test_invoice(&env, &business, "paged old", None, None);
old_invoice.created_at = 1000;
let mut mid_invoice = create_test_invoice(&env, &business, "paged mid", None, None);
mid_invoice.created_at = 2000;
let mut new_invoice = create_test_invoice(&env, &business, "paged new", None, None);
new_invoice.created_at = 3000;

let query_first = String::from_str(&env, "paged");
let query_second = String::from_str(&env, "paged");
let (first_page, second_page) = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &old_invoice);
InvoiceStorage::store_invoice(&env, &mid_invoice);
InvoiceStorage::store_invoice(&env, &new_invoice);

(
InvoiceSearch::search_invoices_paged(&env, query_first, 0, 2).unwrap(),
InvoiceSearch::search_invoices_paged(&env, query_second, 2, 2).unwrap(),
)
});

assert_eq!(first_page.len(), 2);
assert_eq!(second_page.len(), 1);
assert_eq!(first_page.get(0).unwrap().invoice_id, new_invoice.id);
assert_eq!(first_page.get(1).unwrap().invoice_id, mid_invoice.id);
assert_eq!(second_page.get(0).unwrap().invoice_id, old_invoice.id);
}

#[test]
fn test_search_invoices_wrapper_matches_first_max_page() {
let env = setup_test_env();
let business = Address::generate(&env);

let query_full = String::from_str(&env, "paged");
let query_paged = String::from_str(&env, "paged");
let (wrapped, paged) = with_contract(&env, || {
for i in 0..55 {
let description = format!("paged service {}", i);
let mut invoice = create_test_invoice(&env, &business, &description, None, None);
invoice.created_at = i;
InvoiceStorage::store_invoice(&env, &invoice);
}

(
InvoiceSearch::search_invoices(&env, query_full).unwrap(),
InvoiceSearch::search_invoices_paged(&env, query_paged, 0, MAX_SEARCH_RESULTS)
.unwrap(),
)
});

assert_eq!(wrapped, paged);
assert_eq!(wrapped.len() as u32, MAX_SEARCH_RESULTS);
}

#[test]
fn test_search_invoices_relevance_ordering() {
let env = setup_test_env();
let business = Address::generate(&env);

// Create invoices at different times
let mut invoice1 = create_test_invoice(&env, &business, "old service", Some("Test Corp"), None);
let mut invoice1 =
create_test_invoice(&env, &business, "old service", Some("Test Corp"), None);
invoice1.created_at = 1000;

let mut invoice2 = create_test_invoice(&env, &business, "new service", Some("Test Corp"), None);
let mut invoice2 =
create_test_invoice(&env, &business, "new service", Some("Test Corp"), None);
invoice2.created_at = 2000;

// Store invoices
Expand All @@ -261,4 +432,4 @@ mod test_invoice_search {
assert_eq!(results.get(0).unwrap().created_at, 2000);
assert_eq!(results.get(1).unwrap().created_at, 1000);
}
}
}
Loading