use serde_json::json;
use helios_persistence::core::{ResourceStorage, SearchProvider};
use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions};
use helios_persistence::types::{
Pagination, SearchModifier, SearchParamType, SearchParameter, SearchQuery, SearchValue,
};
#[cfg(feature = "sqlite")]
use helios_persistence::backends::sqlite::SqliteBackend;
#[cfg(feature = "sqlite")]
fn create_sqlite_backend() -> SqliteBackend {
let backend = SqliteBackend::in_memory().expect("Failed to create SQLite backend");
backend.init_schema().expect("Failed to initialize schema");
backend
}
fn create_tenant() -> TenantContext {
TenantContext::new(TenantId::new("test-tenant"), TenantPermissions::full_access())
}
#[cfg(feature = "sqlite")]
async fn seed_test_patients(backend: &SqliteBackend, tenant: &TenantContext) {
let patients = vec![
json!({"resourceType": "Patient", "name": [{"family": "Smith", "given": ["John", "Jacob"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "Smith", "given": ["Jane"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "Smithson", "given": ["Robert"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "Johnson", "given": ["Emily"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "SMITH", "given": ["Michael"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "O'Brien", "given": ["Patrick"]}]}),
json!({"resourceType": "Patient", "name": [{"family": "Van Der Berg", "given": ["Anna"]}]}),
];
for patient in patients {
backend.create(tenant, "Patient", patient).await.unwrap();
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_default() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(result.resources.len() >= 2);
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
family.starts_with("smith"),
"Family '{}' should start with 'smith'",
family
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_case_insensitive() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("smith")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_exact() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: Some(SearchModifier::Exact),
values: vec![SearchValue::eq("Smith")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
for resource in &result.resources {
let family = resource.content()["name"][0]["family"].as_str().unwrap();
assert_eq!(family, "Smith", "Should only match exact 'Smith'");
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_exact_case_sensitive() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: Some(SearchModifier::Exact),
values: vec![SearchValue::eq("smith")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_contains() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: Some(SearchModifier::Contains),
values: vec![SearchValue::eq("son")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(result.resources.len() >= 2);
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
family.contains("son"),
"Family '{}' should contain 'son'",
family
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_given_name() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("John")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty());
for resource in &result.resources {
let given = &resource.content()["name"][0]["given"];
let names: Vec<&str> = given
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
names.iter().any(|n| n.to_lowercase().starts_with("john")),
"Should have given name starting with 'John'"
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_combined_name() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient")
.with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith")],
chain: vec![],
components: vec![],
})
.with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("John")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty());
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
assert!(family.starts_with("smith"));
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_apostrophe() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("O'Brien")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_spaces() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Van Der Berg")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_or_values() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith"), SearchValue::eq("Johnson")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
family.starts_with("smith") || family.starts_with("johnson"),
"Family '{}' should start with 'smith' or 'johnson'",
family
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_no_results() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("NonexistentName")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_string_search_empty_storage() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(result.resources.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_multivalue_or_semantics() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith"), SearchValue::eq("Johnson")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
assert!(!result.resources.is_empty(), "Should find matching resources");
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
family.starts_with("smith") || family.starts_with("johnson"),
"Family '{}' should match 'smith' or 'johnson'",
family
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_multivalue_and_semantics() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient")
.with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith")],
chain: vec![],
components: vec![],
})
.with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("John")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
let given = &resource.content()["name"][0]["given"];
let given_names: Vec<String> = given
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.collect();
assert!(
family.starts_with("smith"),
"Family '{}' should start with 'smith'",
family
);
assert!(
given_names.iter().any(|n| n.starts_with("john")),
"Given names {:?} should include one starting with 'john'",
given_names
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_multivalue_combined_and_or_semantics() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient")
.with_parameter(SearchParameter {
name: "family".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Smith"), SearchValue::eq("Johnson")],
chain: vec![],
components: vec![],
})
.with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("John")],
chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
for resource in &result.resources {
let family = resource.content()["name"][0]["family"]
.as_str()
.unwrap()
.to_lowercase();
let given = &resource.content()["name"][0]["given"];
let given_names: Vec<String> = given
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.collect();
assert!(
family.starts_with("smith") || family.starts_with("johnson"),
"Family '{}' should match smith or johnson",
family
);
assert!(
given_names.iter().any(|n| n.starts_with("john")),
"Must have given name starting with john"
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_repeated_parameter_and_semantics() {
let backend = create_sqlite_backend();
let tenant = create_tenant();
seed_test_patients(&backend, &tenant).await;
let query = SearchQuery::new("Patient")
.with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Jo")], chain: vec![],
components: vec![],
})
.with_parameter(SearchParameter {
name: "given".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::eq("Ja")], chain: vec![],
components: vec![],
});
let result = backend
.search(&tenant, &query, Pagination::new(100))
.await
.unwrap();
for resource in &result.resources {
let given = &resource.content()["name"][0]["given"];
let given_names: Vec<String> = given
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.collect();
let has_jo = given_names.iter().any(|n| n.starts_with("jo"));
let has_ja = given_names.iter().any(|n| n.starts_with("ja"));
assert!(
has_jo && has_ja,
"Given names {:?} should have both Jo* and Ja*",
given_names
);
}
}