use super::*;
static OVERLAY_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const MARKER_PROVIDER: &str = "zzz-catalog-refresh-test-provider";
const MARKER_MODEL: &str = "zzz-catalog-refresh-test-model";
const MARKER_INPUT_COST: f64 = 0.05;
const MARKER_OUTPUT_COST: f64 = 0.1;
fn marker_key() -> String {
format!("{MARKER_PROVIDER}/{MARKER_MODEL}")
}
fn build_overlay_fixture() -> String {
let mut value: serde_json::Value =
serde_json::from_str(super::CATALOG_JSON).expect("embedded catalog.json must parse as JSON");
let providers = value
.get_mut("providers")
.and_then(serde_json::Value::as_object_mut)
.expect("embedded catalog.json must have a top-level `providers` object");
let marker_provider_json = format!(
r#"{{
"models": {{
"{MARKER_MODEL}": {{
"pricing": {{
"input_cost_per_token": {MARKER_INPUT_COST},
"output_cost_per_token": {MARKER_OUTPUT_COST}
}},
"limit": {{ "context": 128000, "output": 16384 }},
"mode": "chat",
"capabilities": {{
"vision": false, "function_calling": false, "reasoning": false,
"structured_output": false, "audio_input": false, "audio_output": false,
"prompt_caching": false
}}
}}
}}
}}"#
);
let marker_provider_value: serde_json::Value =
serde_json::from_str(&marker_provider_json).expect("marker provider fixture JSON must parse");
providers.insert(MARKER_PROVIDER.to_string(), marker_provider_value);
serde_json::to_string(&value).expect("modified catalog value must serialize")
}
fn unique_cache_path(suffix: &str) -> String {
std::env::temp_dir()
.join(format!("liter-llm-catalog-refresh-test-{suffix}.json"))
.to_string_lossy()
.into_owned()
}
fn remove_cache_file(path: &str) {
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn refresh_disabled_returns_disabled_and_leaves_embedded_pricing_active() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
let config = CatalogRefreshConfig::default();
assert!(
!config.enabled,
"CatalogRefreshConfig::default() must be opt-in (enabled: false)"
);
let outcome = refresh_catalog(&config).await;
assert!(
matches!(outcome, Ok(RefreshOutcome::Disabled)),
"expected Ok(Disabled), got {outcome:?}"
);
assert!(
super::refresh::overlay_registry().is_none(),
"a disabled refresh must never install an overlay"
);
let cost = completion_cost("gpt-4", 100, 50).expect("gpt-4 must be in the embedded registry");
let expected = 100.0 * 0.00003 + 50.0 * 0.00006;
assert!(
(cost - expected).abs() < 1e-12,
"expected embedded price {expected}, got {cost}"
);
clear_catalog_overlay();
}
#[tokio::test]
async fn install_overlay_from_str_overrides_pricing_then_clear_reverts_to_embedded() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
let key = marker_key();
assert!(
completion_cost(&key, 1_000, 500).is_none(),
"the marker model must not exist in the embedded catalog"
);
assert!(
model_info(&key).is_none(),
"the marker model must not exist in the embedded catalog"
);
let fixture = build_overlay_fixture();
install_catalog_overlay_from_str(&fixture).expect("fixture catalog JSON must parse");
let overlay_cost =
completion_cost(&key, 1_000, 500).expect("marker model must resolve once the overlay is installed");
let overlay_expected = 1_000.0 * MARKER_INPUT_COST + 500.0 * MARKER_OUTPUT_COST;
assert!(
(overlay_cost - overlay_expected).abs() < 1e-9,
"expected overlay price {overlay_expected}, got {overlay_cost}"
);
let info = model_info(&key).expect("marker model must resolve once the overlay is installed");
assert!(
(info.input_cost_per_token - MARKER_INPUT_COST).abs() < 1e-12,
"model_info must reflect the overlay's input_cost_per_token, got {}",
info.input_cost_per_token
);
assert!(
(info.output_cost_per_token - MARKER_OUTPUT_COST).abs() < 1e-12,
"model_info must reflect the overlay's output_cost_per_token, got {}",
info.output_cost_per_token
);
let gpt4o_cost = completion_cost("gpt-4o", 1_000, 500).expect("gpt-4o must still resolve through the overlay");
let gpt4o_expected = 1_000.0 * 0.0000025 + 500.0 * 0.00001;
assert!(
(gpt4o_cost - gpt4o_expected).abs() < 1e-12,
"expected unrelated embedded model to keep its embedded price {gpt4o_expected}, got {gpt4o_cost}"
);
clear_catalog_overlay();
assert!(
completion_cost(&key, 1_000, 500).is_none(),
"clearing the overlay must revert the marker model to unresolved"
);
assert!(
model_info(&key).is_none(),
"clearing the overlay must revert the marker model to unresolved"
);
}
#[tokio::test]
async fn refresh_against_unreachable_host_fails_but_embedded_pricing_still_works() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
let cache_path = unique_cache_path("air-gap-fallback");
remove_cache_file(&cache_path);
let config = CatalogRefreshConfig {
enabled: true,
source_url: "https://127.0.0.1:1/catalog.json".to_string(),
ttl_seconds: 86_400,
cache_path: Some(cache_path.clone()),
};
let outcome = refresh_catalog(&config).await;
assert!(
outcome.is_err(),
"refresh against an unreachable host must fail, got {outcome:?}"
);
assert!(
matches!(outcome, Err(CatalogRefreshError::Fetch { .. })),
"expected Fetch error, got {outcome:?}"
);
assert!(
super::refresh::overlay_registry().is_none(),
"a failed refresh must never install an overlay (air-gap safety)"
);
let cost = completion_cost("gpt-4o", 1_000, 500).expect("gpt-4o must still resolve via the embedded catalog");
let expected = 1_000.0 * 0.0000025 + 500.0 * 0.00001;
assert!(
(cost - expected).abs() < 1e-12,
"expected embedded fallback price {expected}, got {cost}"
);
remove_cache_file(&cache_path);
clear_catalog_overlay();
}
#[tokio::test]
async fn refresh_with_non_https_url_returns_insecure_url_error() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
let cache_path = unique_cache_path("insecure-url");
remove_cache_file(&cache_path);
let config = CatalogRefreshConfig {
enabled: true,
source_url: "http://example.com/catalog.json".to_string(),
ttl_seconds: 86_400,
cache_path: Some(cache_path.clone()),
};
let outcome = refresh_catalog(&config).await;
assert!(
matches!(outcome, Err(CatalogRefreshError::InsecureUrl { .. })),
"expected InsecureUrl error, got {outcome:?}"
);
assert!(
super::refresh::overlay_registry().is_none(),
"a rejected URL must never install an overlay"
);
remove_cache_file(&cache_path);
clear_catalog_overlay();
}