use std::collections::{HashMap, HashSet};
use std::time::Duration;
use crate::converter::validator::validate_cas_format;
use crate::error::SdsError;
use crate::schema::SdsRoot;
const PUBCHEM_BASE_URL: &str = "https://pubchem.ncbi.nlm.nih.gov/rest/pug";
const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(210);
const MAX_RETRIES: u32 = 3;
const BASE_BACKOFF: Duration = Duration::from_millis(250);
const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
const MAX_FAULT_MESSAGE_LEN: usize = 2048;
#[derive(Debug, Clone)]
pub struct CasInfo {
pub cas: String,
pub iupac_name: Option<String>,
pub molecular_formula: Option<String>,
pub pubchem_cid: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CasWarning {
NotFound {
cas: String,
},
NameMismatch {
cas: String,
pubchem_name: String,
sds_name: String,
},
}
impl std::fmt::Display for CasWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CasWarning::NotFound { cas } => write!(f, "CAS {cas}: not found in PubChem"),
CasWarning::NameMismatch {
cas,
pubchem_name,
sds_name,
} => write!(
f,
"CAS {cas}: PubChem name '{pubchem_name}' differs from SDS name '{sds_name}'"
),
}
}
}
pub async fn lookup_cas(cas: &str, client: &reqwest::Client) -> Result<Option<CasInfo>, SdsError> {
if !validate_cas_format(cas) {
return Err(SdsError::Extract(format!("Invalid CAS number: {cas:?}")));
}
let url = format!(
"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{cas}/property/IUPACName,MolecularFormula,CID/JSON"
);
let mut resp = client
.get(&url)
.send()
.await
.map_err(|e| SdsError::Extract(format!("PubChem request failed: {e}")))?;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
resp = client
.get(&url)
.send()
.await
.map_err(|e| SdsError::Extract(format!("PubChem request failed (retry): {e}")))?;
}
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(SdsError::Extract(format!(
"PubChem returned HTTP {}",
resp.status()
)));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
let props = body
.pointer("/PropertyTable/Properties/0")
.cloned()
.unwrap_or(serde_json::Value::Null);
Ok(Some(CasInfo {
cas: cas.to_string(),
iupac_name: props["IUPACName"].as_str().map(str::to_string),
molecular_formula: props["MolecularFormula"].as_str().map(str::to_string),
pubchem_cid: props["CID"].as_u64(),
}))
}
pub async fn enrich_composition(sds: &SdsRoot, client: &reqwest::Client) -> Vec<CasWarning> {
let mut warnings = Vec::new();
let items = sds
.composition
.as_ref()
.and_then(|c| c.composition_and_concentration.as_deref())
.unwrap_or(&[]);
for item in items {
let sds_name = item
.substance_identifiers
.as_ref()
.and_then(|ids| ids.substance_names.as_ref())
.and_then(|sn| {
sn.iupac_name
.as_deref()
.or(sn.cas_inventory_name.as_deref())
.or(sn.generic_name.as_deref())
})
.unwrap_or("")
.to_string();
let cas_numbers: Vec<String> = item
.substance_identifiers
.as_ref()
.and_then(|ids| ids.substance_identity.as_ref())
.and_then(|si| si.ca_sno.as_ref())
.and_then(|c| c.full_text.as_deref())
.unwrap_or(&[])
.to_vec();
for cas in &cas_numbers {
match lookup_cas(cas, client).await {
Ok(None) => warnings.push(CasWarning::NotFound { cas: cas.clone() }),
Ok(Some(info)) => {
if let Some(pubchem_name) = &info.iupac_name {
if !sds_name.is_empty() && !names_similar(pubchem_name, &sds_name) {
warnings.push(CasWarning::NameMismatch {
cas: cas.clone(),
pubchem_name: pubchem_name.clone(),
sds_name: sds_name.clone(),
});
}
}
}
Err(e) => {
tracing::warn!("PubChem lookup error for CAS {cas}: {e}");
}
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
warnings
}
#[derive(Debug, Clone)]
pub struct ChemicalIdentityCandidate {
pub cas: String,
pub pubchem_cid: Option<u64>,
pub iupac_name: Option<String>,
pub molecular_formula: Option<String>,
pub smiles: Option<String>,
pub connectivity_smiles: Option<String>,
pub inchi_key: Option<String>,
}
#[derive(Debug, Clone)]
pub enum CasResolution {
Resolved(ChemicalIdentityCandidate),
NotFound,
Ambiguous(Vec<ChemicalIdentityCandidate>),
}
fn parse_cid_list(body: &serde_json::Value) -> Vec<u64> {
let mut seen = HashSet::new();
body.pointer("/IdentifierList/CID")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_u64())
.filter(|cid| seen.insert(*cid))
.collect()
})
.unwrap_or_default()
}
fn parse_candidates_by_cid(
cas: &str,
body: &serde_json::Value,
) -> HashMap<u64, ChemicalIdentityCandidate> {
body.pointer("/PropertyTable/Properties")
.and_then(|v| v.as_array())
.map(|props| {
props
.iter()
.filter_map(|p| {
let cid = p["CID"].as_u64()?;
Some((cid, candidate_from_properties(cas, cid, p)))
})
.collect()
})
.unwrap_or_default()
}
fn candidate_from_properties(
cas: &str,
cid: u64,
props: &serde_json::Value,
) -> ChemicalIdentityCandidate {
ChemicalIdentityCandidate {
cas: cas.to_string(),
pubchem_cid: Some(cid),
iupac_name: props["IUPACName"].as_str().map(str::to_string),
molecular_formula: props["MolecularFormula"].as_str().map(str::to_string),
smiles: props["SMILES"].as_str().map(str::to_string),
connectivity_smiles: props["ConnectivitySMILES"].as_str().map(str::to_string),
inchi_key: props["InChIKey"].as_str().map(str::to_string),
}
}
fn build_resolution(
cas: &str,
cids: &[u64],
mut properties_by_cid: HashMap<u64, ChemicalIdentityCandidate>,
) -> CasResolution {
let candidates: Vec<ChemicalIdentityCandidate> = cids
.iter()
.map(|cid| {
properties_by_cid
.remove(cid)
.unwrap_or_else(|| ChemicalIdentityCandidate {
cas: cas.to_string(),
pubchem_cid: Some(*cid),
iupac_name: None,
molecular_formula: None,
smiles: None,
connectivity_smiles: None,
inchi_key: None,
})
})
.collect();
match candidates.len() {
0 => CasResolution::NotFound,
1 => CasResolution::Resolved(candidates.into_iter().next().expect("len checked above")),
_ => CasResolution::Ambiguous(candidates),
}
}
fn truncate_bounded(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
fn format_pubchem_fault(status: reqwest::StatusCode, body: &str) -> String {
let detail = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
let message = v
.pointer("/Fault/Message")
.and_then(|m| m.as_str())
.map(str::to_string);
let details = v
.pointer("/Fault/Details")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str())
.collect::<Vec<_>>()
.join("; ")
});
match (message, details) {
(Some(m), Some(d)) if !d.is_empty() => Some(format!("{m} — {d}")),
(Some(m), _) => Some(m),
(None, Some(d)) if !d.is_empty() => Some(d),
_ => None,
}
})
.unwrap_or_else(|| body.to_string());
format!(
"PubChem returned HTTP {status}: {}",
truncate_bounded(&detail, MAX_FAULT_MESSAGE_LEN)
)
}
async fn describe_pubchem_error(status: reqwest::StatusCode, resp: reqwest::Response) -> SdsError {
let body = resp.text().await.unwrap_or_default();
SdsError::Extract(format_pubchem_fault(status, &body))
}
fn retry_after_duration(resp: &reqwest::Response) -> Option<Duration> {
resp.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(|secs| Duration::from_secs(secs).min(MAX_RETRY_AFTER))
}
async fn get_with_retry(
client: &reqwest::Client,
url: &str,
) -> Result<reqwest::Response, SdsError> {
let mut attempt = 0;
loop {
tokio::time::sleep(MIN_REQUEST_INTERVAL).await;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SdsError::Extract(format!("PubChem request failed: {e}")))?;
let status = resp.status();
let retryable = status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| status == reqwest::StatusCode::SERVICE_UNAVAILABLE;
if !retryable || attempt >= MAX_RETRIES {
return Ok(resp);
}
let delay = retry_after_duration(&resp).unwrap_or_else(|| BASE_BACKOFF * 2u32.pow(attempt));
tokio::time::sleep(delay).await;
attempt += 1;
}
}
async fn resolve_cids(
cas: &str,
client: &reqwest::Client,
base_url: &str,
) -> Result<Vec<u64>, SdsError> {
let url = format!("{base_url}/compound/name/{cas}/cids/JSON?name_type=complete");
let resp = get_with_retry(client, &url).await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
if !resp.status().is_success() {
let status = resp.status();
return Err(describe_pubchem_error(status, resp).await);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
Ok(parse_cid_list(&body))
}
async fn fetch_properties_by_cid(
cas: &str,
cids: &[u64],
client: &reqwest::Client,
base_url: &str,
) -> Result<HashMap<u64, ChemicalIdentityCandidate>, SdsError> {
let cid_list = cids
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(",");
let url = format!(
"{base_url}/compound/cid/{cid_list}/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"
);
let resp = get_with_retry(client, &url).await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(HashMap::new());
}
if !resp.status().is_success() {
let status = resp.status();
return Err(describe_pubchem_error(status, resp).await);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
Ok(parse_candidates_by_cid(cas, &body))
}
pub async fn lookup_cas_detailed(
cas: &str,
client: &reqwest::Client,
) -> Result<CasResolution, SdsError> {
lookup_cas_detailed_at(cas, client, PUBCHEM_BASE_URL).await
}
async fn lookup_cas_detailed_at(
cas: &str,
client: &reqwest::Client,
base_url: &str,
) -> Result<CasResolution, SdsError> {
if !validate_cas_format(cas) {
return Err(SdsError::Extract(format!("Invalid CAS number: {cas:?}")));
}
let cids = resolve_cids(cas, client, base_url).await?;
if cids.is_empty() {
return Ok(CasResolution::NotFound);
}
let properties_by_cid = fetch_properties_by_cid(cas, &cids, client, base_url).await?;
Ok(build_resolution(cas, &cids, properties_by_cid))
}
fn names_similar(a: &str, b: &str) -> bool {
let a_lo = a.to_lowercase();
let b_lo = b.to_lowercase();
let words_a: HashSet<&str> = a_lo.split_whitespace().collect();
let words_b: HashSet<&str> = b_lo.split_whitespace().collect();
if words_a.is_empty() || words_b.is_empty() {
return false;
}
let intersection = words_a.intersection(&words_b).count();
let union = words_a.union(&words_b).count();
(intersection as f64 / union as f64) >= 0.5
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn names_similar_identical() {
assert!(names_similar("acetic acid", "acetic acid"));
}
#[test]
fn names_similar_partial_overlap_above_threshold() {
assert!(names_similar("acetic acid solution", "acetic acid"));
}
#[test]
fn names_similar_no_overlap() {
assert!(!names_similar("sodium chloride", "acetic acid"));
}
#[test]
fn names_similar_short_common_word_no_false_positive() {
assert!(!names_similar("salt", "sodium chloride"));
}
#[test]
fn names_similar_empty_string() {
assert!(!names_similar("", "acetic acid"));
assert!(!names_similar("acetic acid", ""));
}
#[test]
fn parse_cid_list_empty() {
let body = serde_json::json!({ "IdentifierList": { "CID": [] } });
assert_eq!(parse_cid_list(&body), Vec::<u64>::new());
}
#[test]
fn parse_cid_list_missing_key() {
let body = serde_json::json!({});
assert_eq!(parse_cid_list(&body), Vec::<u64>::new());
}
#[test]
fn parse_cid_list_single() {
let body = serde_json::json!({ "IdentifierList": { "CID": [702] } });
assert_eq!(parse_cid_list(&body), vec![702]);
}
#[test]
fn parse_cid_list_deduplicates_preserving_order() {
let body = serde_json::json!({ "IdentifierList": { "CID": [5, 3, 5, 1, 3] } });
assert_eq!(parse_cid_list(&body), vec![5, 3, 1]);
}
#[test]
fn candidate_parsing_maps_smiles() {
let body = serde_json::json!({
"PropertyTable": { "Properties": [
{"CID": 702, "SMILES": "CCO", "IUPACName": "ethanol"}
] }
});
let by_cid = parse_candidates_by_cid("64-17-5", &body);
assert_eq!(by_cid[&702].smiles.as_deref(), Some("CCO"));
}
#[test]
fn candidate_parsing_maps_connectivity_smiles() {
let body = serde_json::json!({
"PropertyTable": { "Properties": [
{"CID": 702, "ConnectivitySMILES": "CCO"}
] }
});
let by_cid = parse_candidates_by_cid("64-17-5", &body);
assert_eq!(by_cid[&702].connectivity_smiles.as_deref(), Some("CCO"));
}
#[test]
fn build_resolution_zero_cids_is_not_found() {
let resolution = build_resolution("0-00-0", &[], HashMap::new());
assert!(matches!(resolution, CasResolution::NotFound));
}
#[test]
fn build_resolution_one_cid_is_resolved() {
let mut props = HashMap::new();
props.insert(
702,
candidate_from_properties(
"64-17-5",
702,
&serde_json::json!({"IUPACName": "ethanol", "SMILES": "CCO"}),
),
);
let resolution = build_resolution("64-17-5", &[702], props);
match resolution {
CasResolution::Resolved(c) => {
assert_eq!(c.pubchem_cid, Some(702));
assert_eq!(c.iupac_name.as_deref(), Some("ethanol"));
}
other => panic!("expected Resolved, got {other:?}"),
}
}
#[test]
fn build_resolution_two_cids_is_ambiguous_preserving_all_candidates() {
let mut props = HashMap::new();
props.insert(
1,
candidate_from_properties("64-17-5", 1, &serde_json::json!({})),
);
props.insert(
2,
candidate_from_properties("64-17-5", 2, &serde_json::json!({})),
);
let resolution = build_resolution("64-17-5", &[1, 2], props);
match resolution {
CasResolution::Ambiguous(candidates) => {
assert_eq!(candidates.len(), 2);
let cids: Vec<Option<u64>> = candidates.iter().map(|c| c.pubchem_cid).collect();
assert_eq!(cids, vec![Some(1), Some(2)]);
}
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn fault_message_prefers_structured_fault_fields() {
let body = serde_json::json!({
"Fault": {"Code": "PUGREST.BadRequest", "Message": "Invalid property"}
})
.to_string();
let msg = format_pubchem_fault(reqwest::StatusCode::BAD_REQUEST, &body);
assert!(msg.contains("Invalid property"));
assert!(msg.contains("400"));
}
#[test]
fn fault_message_includes_details_when_present() {
let body = serde_json::json!({
"Fault": {
"Code": "PUGREST.NotFound",
"Message": "No CID found",
"Details": ["No CID found that matches the given name"]
}
})
.to_string();
let msg = format_pubchem_fault(reqwest::StatusCode::NOT_FOUND, &body);
assert!(msg.contains("No CID found"));
assert!(msg.contains("matches the given name"));
}
#[test]
fn fault_message_is_bounded() {
let huge = "<html>".to_string() + &"x".repeat(10_000) + "</html>";
let msg = format_pubchem_fault(reqwest::StatusCode::BAD_REQUEST, &huge);
assert!(msg.len() < MAX_FAULT_MESSAGE_LEN + 100);
}
#[test]
fn truncate_bounded_does_not_panic_on_multibyte_boundary() {
let s = "あ".repeat(1000);
let truncated = truncate_bounded(&s, 10);
assert!(truncated.len() <= 13); }
#[tokio::test]
async fn request_uses_smiles_and_connectivity_smiles_not_obsolete_names() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.and(query_param("name_type", "complete"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [702] }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"PropertyTable": { "Properties": [
{"CID": 702, "IUPACName": "ethanol", "MolecularFormula": "C2H6O",
"SMILES": "CCO", "ConnectivitySMILES": "CCO", "InChIKey": "LFQSCWFLJHTTHZ-UHFFFAOYSA-N"}
] }
})))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(matches!(result, Ok(CasResolution::Resolved(_))));
}
#[tokio::test]
async fn zero_cids_is_not_found_without_a_second_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/50-78-2/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [] }
})))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("50-78-2", &client, &server.uri()).await;
assert!(matches!(result, Ok(CasResolution::NotFound)));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn duplicate_cids_are_deduplicated_before_the_property_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [702, 702] }
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"PropertyTable": { "Properties": [{"CID": 702, "IUPACName": "ethanol"}] }
})))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(matches!(result, Ok(CasResolution::Resolved(_))));
}
#[tokio::test]
async fn multiple_distinct_cids_yield_ambiguous() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [1, 2] }
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/cid/1,2/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"PropertyTable": { "Properties": [
{"CID": 1, "IUPACName": "candidate one"},
{"CID": 2, "IUPACName": "candidate two"}
] }
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
match result {
Ok(CasResolution::Ambiguous(candidates)) => assert_eq!(candidates.len(), 2),
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[tokio::test]
async fn http_400_includes_bounded_fault_detail_and_is_not_retried() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"Fault": {"Code": "PUGREST.BadRequest", "Message": "Invalid property"}
})))
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid property"));
assert!(err.contains("400"));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn http_429_is_retried_with_backoff() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(429))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [] }
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(matches!(result, Ok(CasResolution::NotFound)));
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn http_503_is_retried_with_backoff() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [] }
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(matches!(result, Ok(CasResolution::NotFound)));
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn retries_are_capped() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(result.is_err());
assert_eq!(
server.received_requests().await.unwrap().len() as u32,
MAX_RETRIES + 1
);
}
#[tokio::test]
async fn malformed_cas_is_rejected_by_the_format_guard_before_any_request() {
let server = MockServer::start().await;
let client = reqwest::Client::new();
let result = lookup_cas_detailed_at("bad-cas", &client, &server.uri()).await;
assert!(result.is_err());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_pubchem_error_for_one_cas_does_not_prevent_a_later_component_from_resolving() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/compound/name/50-78-2/cids/JSON"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"Fault": {"Code": "PUGREST.BadRequest", "Message": "boom"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/name/64-17-5/cids/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"IdentifierList": { "CID": [702] }
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"PropertyTable": { "Properties": [{"CID": 702, "IUPACName": "ethanol"}] }
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let first = lookup_cas_detailed_at("50-78-2", &client, &server.uri()).await;
assert!(first.is_err());
let second = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
assert!(matches!(second, Ok(CasResolution::Resolved(_))));
}
}