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);
let _ = std::fs::remove_file(format!("{path}.sha256"));
}
fn partial_overlay_fixture() -> String {
format!(
r#"{{
"$provenance": {{
"source": "test/source.json",
"source_sha256": "{}",
"fetched": "2026-10-09",
"library_version": "2.2.0"
}},
"$schema_version": 1,
"providers": {{
"{MARKER_PROVIDER}": {{
"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
}}
}}
}}
}}
}}
}}"#,
"0".repeat(64)
)
}
#[tokio::test]
async fn partial_overlay_layers_over_embedded_catalog_and_reports_provenance() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
install_catalog_overlay_from_str(&partial_overlay_fixture()).expect("partial overlay must install");
assert!(model_info(&marker_key()).is_some(), "overlay marker must resolve");
assert!(
model_info("gpt-4").is_some(),
"missing overlay entry must fall back to embedded catalog"
);
let info = catalog_info().expect("active catalog provenance must be available");
assert_eq!(info.origin, "overlay");
assert_eq!(info.source, "test/source.json");
clear_catalog_overlay();
assert_eq!(
catalog_info().expect("embedded provenance must be available").origin,
"embedded"
);
}
#[tokio::test]
async fn fresh_cache_with_wrong_checksum_is_rejected() {
let _guard = OVERLAY_TEST_LOCK.lock().await;
clear_catalog_overlay();
let cache_path = unique_cache_path("wrong-checksum");
remove_cache_file(&cache_path);
let fixture = partial_overlay_fixture();
std::fs::write(&cache_path, fixture).expect("cache fixture must be writable");
std::fs::write(
format!("{cache_path}.sha256"),
format!("{} catalog.json\n", "f".repeat(64)),
)
.expect("checksum fixture must be writable");
let config = CatalogRefreshConfig {
enabled: true,
source_url: DEFAULT_CATALOG_URL.to_string(),
ttl_seconds: u64::MAX,
cache_path: Some(cache_path.clone()),
};
assert!(matches!(
refresh_catalog(&config).await,
Err(CatalogRefreshError::Integrity { .. })
));
assert!(super::refresh::overlay_registry().is_none());
remove_cache_file(&cache_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();
}