use super::*;
use crate::test_utils::{MockHttpResponse, SequencedHttpClient};
#[tokio::test]
async fn a_mis_targeting_handle_never_reaches_the_socket() {
for smuggled in ["abc?stale", "abc#frag", "abc/def", ""] {
let http_client = SequencedHttpClient::default();
let caches = bound_caches(http_client.clone());
let outcomes = [
("get", caches.get(smuggled).await.err()),
("delete", caches.delete(smuggled).await.err()),
(
"update_expiry",
caches
.update_expiry(smuggled, CacheExpiry::ttl(Duration::from_secs(60)))
.await
.err(),
),
];
for (label, error) in outcomes {
let error =
error.unwrap_or_else(|| panic!("{label} should refuse the handle {smuggled:?}"));
assert!(
matches!(error, ProviderError::Request(_)),
"{label} on {smuggled:?}: {error:?}"
);
}
assert!(
http_client.requests().is_empty(),
"handle {smuggled:?} escaped the process: {:?}",
http_client.requests()
);
}
}
#[test]
fn ttl_serializes_in_geminis_duration_form() {
assert_eq!(
CacheExpiry::ttl_string(Duration::from_secs(600)),
"600.000000000s"
);
}
fn cached_page(names: &[&str], next_page_token: Option<&str>) -> MockHttpResponse {
let cached_contents: Vec<_> = names
.iter()
.map(|name| serde_json::json!({ "name": format!("cachedContents/{name}") }))
.collect();
MockHttpResponse::success(
serde_json::json!({
"cachedContents": cached_contents,
"nextPageToken": next_page_token,
})
.to_string(),
)
}
fn caches(
pages: Vec<MockHttpResponse>,
) -> (
crate::driver::Model<CachedContents, SequencedHttpClient>,
SequencedHttpClient,
) {
let http_client = SequencedHttpClient::new(pages);
(bound_caches(http_client.clone()), http_client)
}
#[tokio::test]
async fn pagination_percent_encodes_the_cursor() {
let (caches, http_client) = caches(vec![
cached_page(&["a"], Some("weird token&x=1")),
cached_page(&["b"], None),
]);
crate::driver::Model::new(
caches.wire.clone().with_page_size(1),
caches.transport.clone(),
)
.list()
.await
.expect("listing should succeed");
let uris: Vec<_> = http_client
.requests()
.into_iter()
.map(|request| request.uri)
.collect();
assert!(
uris[1].contains("pageSize=1&pageToken=weird+token%26x%3D1&key="),
"the cursor must be percent-encoded: {uris:?}",
);
}
fn bound_caches(
http: SequencedHttpClient,
) -> crate::driver::Model<CachedContents, SequencedHttpClient> {
crate::driver::Model::new(
crate::providers::gemini::GeminiConfig::new("test-key").cached_contents(),
http,
)
}
#[tokio::test]
async fn the_bound_cache_sends_each_recorded_path_with_the_key_in_the_query() {
const HANDLE: &str = r#"{"name":"cachedContents/leaky","model":"models/gemini-2.5-flash"}"#;
let http = SequencedHttpClient::new([
MockHttpResponse::success(HANDLE),
MockHttpResponse::success(HANDLE),
MockHttpResponse::success(HANDLE),
MockHttpResponse::success("{}"),
]);
let caches = bound_caches(http.clone());
caches
.create(NewCachedContent::new("gemini-2.5-flash").content("corpus"))
.await
.expect("create decodes");
caches.get("leaky").await.expect("get decodes");
caches
.update_expiry("leaky", CacheExpiry::ttl(Duration::from_secs(600)))
.await
.expect("the patch decodes");
caches.delete("cachedContents/leaky").await.expect("delete");
let uris: Vec<_> = http
.requests()
.into_iter()
.map(|request| request.uri)
.collect();
assert_eq!(
uris,
vec![
"https://generativelanguage.googleapis.com/v1beta/cachedContents?key=test-key",
"https://generativelanguage.googleapis.com/v1beta/cachedContents/leaky?key=test-key",
"https://generativelanguage.googleapis.com/v1beta/cachedContents/leaky?updateMask=ttl&key=test-key",
"https://generativelanguage.googleapis.com/v1beta/cachedContents/leaky?key=test-key",
]
);
}
#[test]
fn a_serialized_wire_carries_no_key_material_and_round_trips() {
let wire = crate::providers::gemini::GeminiConfig::new("AIzaSyNOTAREALKEY-0123456789")
.cached_contents()
.with_page_size(7);
let json = serde_json::to_string(&wire).expect("the wire serializes");
assert!(
!json.contains("AIzaSyNOTAREALKEY"),
"the serialized wire leaked the key: {json}"
);
assert!(!format!("{wire:?}").contains("AIzaSyNOTAREALKEY"));
let restored: CachedContents = serde_json::from_str(&json).expect("the wire deserializes");
assert_eq!(restored.page_size, 7);
assert_eq!(restored.provider.base_url, wire.provider.base_url);
}
#[tokio::test]
async fn an_empty_body_acknowledges_a_delete_but_answers_no_get() {
let caches = bound_caches(SequencedHttpClient::new([
MockHttpResponse::success(""),
MockHttpResponse::success(""),
]));
caches
.delete("cachedContents/leaky")
.await
.expect("an empty 200 acknowledges the delete");
let error = caches
.get("cachedContents/leaky")
.await
.expect_err("an empty 200 carries no resource");
assert!(matches!(error, ProviderError::Response(_)), "{error:?}");
}
#[tokio::test]
async fn a_reply_of_the_other_shape_names_the_shape_it_carried() {
let caches = bound_caches(SequencedHttpClient::new([
cached_page(&["a"], None),
MockHttpResponse::success(r#"{"name":"cachedContents/leaky"}"#),
]));
let on_get = caches
.get("leaky")
.await
.expect_err("a listing page is not one cached content");
assert!(
matches!(&on_get, ProviderError::Response(message) if message.contains("listing page")),
"{on_get:?}"
);
let on_list = caches
.list()
.await
.expect_err("one cached content is not a listing page");
assert!(
matches!(&on_list, ProviderError::Response(message) if message.contains("not a listing page")),
"{on_list:?}"
);
}