diff --git a/.github/workflows/ads-client-tests.yaml b/.github/workflows/ads-client-tests.yaml new file mode 100644 index 0000000000..b6bfb8fa64 --- /dev/null +++ b/.github/workflows/ads-client-tests.yaml @@ -0,0 +1,28 @@ +name: Ads Client Tests + +on: + push: + branches: [main] + paths: + - "components/ads-client/**" + pull_request: + branches: [main] + paths: + - "components/ads-client/**" + + workflow_dispatch: + +jobs: + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: "recursive" + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source $HOME/.cargo/env + rustup toolchain install + - name: Run ads-client integration tests against MARS staging + run: cargo test -p ads-client-integration-tests -- --ignored diff --git a/Cargo.lock b/Cargo.lock index 4e0d6b2cd6..c2f6923806 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,19 @@ dependencies = [ "viaduct-dev", ] +[[package]] +name = "ads-client-integration-tests" +version = "0.1.0" +dependencies = [ + "ads-client", + "mockito", + "serde_json", + "url", + "viaduct", + "viaduct-dev", + "viaduct-hyper", +] + [[package]] name = "aes" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index ad07d4126f..fa6dfbf011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [workspace] resolver = "2" -# Note: Any additions here should be repeated in default-members below. +# Note: Any additions here should be repeated in default-members below (unless +# the crate should not be built by default, e.g. integration-test-only crates). members = [ "components/ads-client", + "components/ads-client/integration-tests", "components/as-ohttp-client", "components/autofill", "components/context_id", diff --git a/components/ads-client/README.md b/components/ads-client/README.md index e6473eb7ef..fbe9a0a85b 100644 --- a/components/ads-client/README.md +++ b/components/ads-client/README.md @@ -24,21 +24,23 @@ cargo test -p ads-client ### Integration Tests -Integration tests make real HTTP calls to the Mozilla Ads Routing Service (MARS) and are not run automatically in CI. They are marked with `#[ignore]` and must be run manually. +Integration tests make real HTTP calls to the Mozilla Ads Routing Service (MARS) staging environment. They live in a dedicated crate (`integration-tests/`) and are marked `#[ignore]` so they do not run with a plain `cargo test`. -To run integration tests: +They are run by the dedicated GitHub Actions workflow (`.github/workflows/ads-client-tests.yaml`), and can also be run manually: ```shell -cargo test -p ads-client --test integration_test -- --ignored +cargo test -p ads-client-integration-tests -- --ignored ``` -To run a specific integration test: +To run a specific test file or test: ```shell -cargo test -p ads-client --test integration_test -- --ignored test_mock_pocket_billboard_1_placement +cargo test -p ads-client-integration-tests --test mars -- --ignored +cargo test -p ads-client-integration-tests --test http_cache -- --ignored +cargo test -p ads-client-integration-tests --test mars test_contract_image_staging -- --ignored ``` -**Note:** Integration tests require network access and will make real HTTP requests to the MARS API. +**Note:** Integration tests require network access and will make real HTTP requests to the MARS staging API. ## Usage diff --git a/components/ads-client/integration-tests/Cargo.toml b/components/ads-client/integration-tests/Cargo.toml new file mode 100644 index 0000000000..d78ce2c8e1 --- /dev/null +++ b/components/ads-client/integration-tests/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ads-client-integration-tests" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +publish = false + +[dependencies] + +[dev-dependencies] +ads-client = { path = ".." } +serde_json = "1" +url = "2" +mockito = { version = "0.31", default-features = false } +viaduct = { path = "../../../components/viaduct" } +viaduct-dev = { path = "../../../components/support/viaduct-dev" } +viaduct-hyper = { path = "../../../components/support/viaduct-hyper" } diff --git a/components/ads-client/integration-tests/tests/http_cache.rs b/components/ads-client/integration-tests/tests/http_cache.rs new file mode 100644 index 0000000000..32a9f4889f --- /dev/null +++ b/components/ads-client/integration-tests/tests/http_cache.rs @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use std::hash::{Hash, Hasher}; +use std::time::Duration; + +use ads_client::http_cache::{ByteSize, CacheMode, CacheOutcome, HttpCache, RequestCachePolicy}; +use mockito::mock; +use viaduct::Request; + +/// Test-only hashable wrapper around Request. +#[derive(Clone)] +struct TestRequest(Request); + +impl Hash for TestRequest { + fn hash(&self, state: &mut H) { + self.0.method.as_str().hash(state); + self.0.url.as_str().hash(state); + } +} + +impl From for Request { + fn from(t: TestRequest) -> Self { + t.0 + } +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_cache_works_using_real_timeouts() { + viaduct_dev::init_backend_dev(); + + let cache = HttpCache::::builder("integration_tests.db") + .default_ttl(Duration::from_secs(60)) + .max_size(ByteSize::mib(1)) + .build() + .expect("cache build should succeed"); + + let url = format!("{}/v1/ads", mockito::server_url()).parse().unwrap(); + let req = TestRequest(Request::post(url).json(&serde_json::json!({ + "context_id": "12347fff-00b0-aaaa-0978-189231239808", + "placements": [ + { + "placement": "mock_pocket_billboard_1", + "count": 1, + } + ], + }))); + + let test_ttl = 2; + + let _m1 = mock("POST", "/v1/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .expect(1) + .create(); + + // First call: miss -> store + let (_, outcomes) = cache + .send_with_policy( + req.clone(), + &RequestCachePolicy { + mode: CacheMode::CacheFirst, + ttl_seconds: Some(test_ttl), + }, + ) + .unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); + + // Second call: hit (no extra HTTP due to expect(1)) + let (response, outcomes) = cache + .send_with_policy(req.clone(), &RequestCachePolicy::default()) + .unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::Hit)); + assert_eq!(response.status, 200); + + let _m2 = mock("POST", "/v1/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .expect(1) + .create(); + + // Third call: Miss due to timeout for the test_ttl duration + std::thread::sleep(Duration::from_secs(test_ttl)); + let (response, outcomes) = cache + .send_with_policy(req, &RequestCachePolicy::default()) + .unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); + assert_eq!(response.status, 200); +} diff --git a/components/ads-client/integration-tests/tests/mars.rs b/components/ads-client/integration-tests/tests/mars.rs new file mode 100644 index 0000000000..24a12da4bc --- /dev/null +++ b/components/ads-client/integration-tests/tests/mars.rs @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use std::sync::Arc; + +use ads_client::{ + MozAdsClientBuilder, MozAdsEnvironment, MozAdsPlacementRequest, MozAdsPlacementRequestWithCount, +}; + +fn init_backend() { + // Err means the backend is already initialized. + let _ = viaduct_hyper::viaduct_init_backend_hyper(); +} + +fn staging_client() -> ads_client::MozAdsClient { + Arc::new(MozAdsClientBuilder::new()) + .environment(MozAdsEnvironment::Staging) + .build() +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_image_staging() { + init_backend(); + + let client = staging_client(); + let result = client.request_image_ads( + vec![MozAdsPlacementRequest { + placement_id: "mock_billboard_1".to_string(), + iab_content: None, + }], + None, + ); + + assert!( + result.is_ok(), + "Image ad request failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + assert!(placements.contains_key("mock_billboard_1")); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_spoc_staging() { + init_backend(); + + let client = staging_client(); + let result = client.request_spoc_ads( + vec![MozAdsPlacementRequestWithCount { + placement_id: "mock_spoc_1".to_string(), + count: 3, + iab_content: None, + }], + None, + ); + + assert!(result.is_ok(), "Spoc ad request failed: {:?}", result.err()); + let placements = result.unwrap(); + assert!(placements.contains_key("mock_spoc_1")); + assert!(placements.get("mock_spoc_1").unwrap().len() == 3); +} + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_tile_staging() { + init_backend(); + + let client = staging_client(); + let result = client.request_tile_ads( + vec![MozAdsPlacementRequest { + placement_id: "mock_tile_1".to_string(), + iab_content: None, + }], + None, + ); + + assert!(result.is_ok(), "Tile ad request failed: {:?}", result.err()); + let placements = result.unwrap(); + assert!(placements.contains_key("mock_tile_1")); +} diff --git a/components/ads-client/src/http_cache.rs b/components/ads-client/src/http_cache.rs index 41de9da039..b3492916e3 100644 --- a/components/ads-client/src/http_cache.rs +++ b/components/ads-client/src/http_cache.rs @@ -22,7 +22,8 @@ use std::cmp; use std::path::Path; use std::time::Duration; -pub type HttpCacheSendResult = std::result::Result; +pub type HttpCacheSendResult = + std::result::Result<(Response, Vec), viaduct::ViaductError>; #[derive(Clone, Copy, Debug, Default)] pub struct RequestCachePolicy { @@ -57,11 +58,6 @@ pub enum CacheOutcome { CleanupFailed(HttpCacheError), // cleaning expired objects failed } -pub struct SendOutcome { - pub response: Response, - pub cache_outcome: CacheOutcome, -} - pub struct HttpCache> { max_size: ByteSize, store: HttpCacheStore, @@ -90,7 +86,8 @@ impl> HttpCache { &self, item: T, request_policy: &RequestCachePolicy, - ) -> HttpCacheSendResult { + ) -> HttpCacheSendResult { + let mut outcomes = vec![]; let request_hash = RequestHash::new(&item); let request: Request = item.into(); let request_policy_ttl = match request_policy.ttl_seconds { @@ -98,25 +95,31 @@ impl> HttpCache { None => self.default_ttl, }; + if let Err(e) = self.store.delete_expired_entries() { + outcomes.push(CacheOutcome::CleanupFailed(e.into())); + } + if request_policy.mode == CacheMode::CacheFirst { match self.store.lookup(&request_hash) { Ok(Some(response)) => { - return Ok(SendOutcome { - response, - cache_outcome: CacheOutcome::Hit, - }); + outcomes.push(CacheOutcome::Hit); + return Ok((response, outcomes)); } Err(e) => { - let mut outcome = + outcomes.push(CacheOutcome::LookupFailed(e)); + let (response, mut fetch_outcomes) = self.fetch_and_cache(&request, &request_hash, &request_policy_ttl)?; - outcome.cache_outcome = CacheOutcome::LookupFailed(e); - return Ok(outcome); + outcomes.append(&mut fetch_outcomes); + return Ok((response, outcomes)); } Ok(None) => {} } } - self.fetch_and_cache(&request, &request_hash, &request_policy_ttl) + let (response, mut fetch_outcomes) = + self.fetch_and_cache(&request, &request_hash, &request_policy_ttl)?; + outcomes.append(&mut fetch_outcomes); + Ok((response, outcomes)) } fn fetch_and_cache( @@ -124,14 +127,8 @@ impl> HttpCache { request: &Request, request_hash: &RequestHash, request_policy_ttl: &Duration, - ) -> HttpCacheSendResult { + ) -> HttpCacheSendResult { let response = request.clone().send()?; - if let Err(e) = self.store.delete_expired_entries() { - return Ok(SendOutcome { - response, - cache_outcome: CacheOutcome::CleanupFailed(e.into()), - }); - } let cache_control = CacheControl::from(&response); let cache_outcome = if cache_control.should_cache() { let response_ttl = match cache_control.max_age { @@ -146,10 +143,7 @@ impl> HttpCache { ); if final_ttl.as_secs() == 0 { - return Ok(SendOutcome { - response, - cache_outcome: CacheOutcome::NoCache, - }); + return Ok((response, vec![CacheOutcome::NoCache])); } match self.cache_object(request_hash, &response, &final_ttl) { @@ -160,10 +154,7 @@ impl> HttpCache { CacheOutcome::MissNotCacheable }; - Ok(SendOutcome { - response, - cache_outcome, - }) + Ok((response, vec![cache_outcome])) } fn cache_object( @@ -289,17 +280,17 @@ mod tests { let req = make_post_request(); // First call: miss -> store - let o1 = cache + let (_, outcomes) = cache .send_with_policy(req.clone(), &RequestCachePolicy::default()) .unwrap(); - matches!(o1.cache_outcome, CacheOutcome::MissStored); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); // Second call: hit (no extra HTTP request due to expect(1)) - let o2 = cache + let (response, outcomes) = cache .send_with_policy(req, &RequestCachePolicy::default()) .unwrap(); - matches!(o2.cache_outcome, CacheOutcome::Hit); - assert_eq!(o2.response.status, 200); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::Hit)); + assert_eq!(response.status, 200); } #[test] @@ -324,7 +315,7 @@ mod tests { let req = make_post_request(); // First refresh: live -> MissStored - let o1 = cache + let (_, outcomes) = cache .send_with_policy( req.clone(), &RequestCachePolicy { @@ -333,10 +324,10 @@ mod tests { }, ) .unwrap(); - matches!(o1.cache_outcome, CacheOutcome::MissStored); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); // Second refresh: live again (different body), still MissStored - let o2 = cache + let (response, outcomes) = cache .send_with_policy( req, &RequestCachePolicy { @@ -345,8 +336,8 @@ mod tests { }, ) .unwrap(); - matches!(o2.cache_outcome, CacheOutcome::MissStored); - assert_eq!(o2.response.status, 200); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); + assert_eq!(response.status, 200); } #[test] @@ -364,10 +355,13 @@ mod tests { let cache = make_cache(); let req = make_post_request(); - let o = cache + let (_, outcomes) = cache .send_with_policy(req.clone(), &RequestCachePolicy::default()) .unwrap(); - matches!(o.cache_outcome, CacheOutcome::MissNotCacheable); + assert!(matches!( + outcomes.last().unwrap(), + CacheOutcome::MissNotCacheable + )); // Next call should hit network again (since we didn't cache) let _m2 = mock("POST", "/ads") @@ -376,12 +370,12 @@ mod tests { .with_body(r#"{"ok":true}"#) .expect(1) .create(); - let o2 = cache + let (_, outcomes) = cache .send_with_policy(req, &RequestCachePolicy::default()) .unwrap(); // Either MissStored (if headers differ) or MissNotCacheable if still no-store assert!(matches!( - o2.cache_outcome, + outcomes.last().unwrap(), CacheOutcome::MissStored | CacheOutcome::MissNotCacheable )); } @@ -407,8 +401,8 @@ mod tests { }; // Store ttl should resolve to 1s as specified by response headers - let out = cache.send_with_policy(req, &policy).unwrap(); - assert!(matches!(out.cache_outcome, CacheOutcome::MissStored)); + let (_, outcomes) = cache.send_with_policy(req, &policy).unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); // After ~>1s, cleanup should remove it cache.store.get_clock().advance(2); @@ -437,8 +431,8 @@ mod tests { }; // Store with effective TTL = 2s - let out = cache.send_with_policy(req, &policy).unwrap(); - assert!(matches!(out.cache_outcome, CacheOutcome::MissStored)); + let (_, outcomes) = cache.send_with_policy(req, &policy).unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); // Not expired yet at ~1s cache.store.get_clock().advance(1); @@ -470,8 +464,8 @@ mod tests { let policy = RequestCachePolicy::default(); // Store with effective TTL = 2s from client default - let out = cache.send_with_policy(req, &policy).unwrap(); - assert!(matches!(out.cache_outcome, CacheOutcome::MissStored)); + let (_, outcomes) = cache.send_with_policy(req, &policy).unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); // Not expired at ~1s cache.store.get_clock().advance(1); @@ -484,6 +478,40 @@ mod tests { assert!(cache.store.lookup(&hash).unwrap().is_none()); } + #[test] + fn test_expired_entry_is_a_miss_on_next_send() { + viaduct_dev::init_backend_dev(); + + let _m1 = mockito::mock("POST", "/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"n":1}"#) + .create(); + let _m2 = mockito::mock("POST", "/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"n":2}"#) + .create(); + + let cache = make_cache_with_ttl(2); + let req = make_post_request(); + + // First call: miss -> store with 2s TTL + let (_, outcomes) = cache + .send_with_policy(req.clone(), &RequestCachePolicy::default()) + .unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); + + // Advance clock past the TTL + cache.store.get_clock().advance(3); + + // Second call: expired entry must be a miss, not a hit + let (_, outcomes) = cache + .send_with_policy(req, &RequestCachePolicy::default()) + .unwrap(); + assert!(matches!(outcomes.last().unwrap(), CacheOutcome::MissStored)); + } + #[test] fn test_invalidate_by_hash() { let cache: HttpCache = diff --git a/components/ads-client/src/http_cache/store.rs b/components/ads-client/src/http_cache/store.rs index 0eaf283c57..74dab586f2 100644 --- a/components/ads-client/src/http_cache/store.rs +++ b/components/ads-client/src/http_cache/store.rs @@ -74,7 +74,7 @@ impl HttpCacheStore { Ok(ByteSize::b(size_bytes)) } - /// Removes all entries from the store who's expires_at is before the current time. + /// Removes all entries from the store whose expires_at is at or before the current time. pub fn delete_expired_entries(&self) -> SqliteResult { #[cfg(test)] if *self.fault.lock() == FaultKind::Cleanup { @@ -82,7 +82,7 @@ impl HttpCacheStore { } let conn = self.conn.lock(); conn.execute( - "DELETE FROM http_cache WHERE expires_at < ?1", + "DELETE FROM http_cache WHERE expires_at <= ?1", params![self.clock.now_epoch_seconds()], ) } diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index c8bc420811..f9b25ab97a 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -55,10 +55,12 @@ where let request_hash = RequestHash::new(&ad_request); let response: AdResponse = if let Some(cache) = self.http_cache.as_ref() { - let outcome = cache.send_with_policy(ad_request, cache_policy)?; - self.telemetry.record(&outcome.cache_outcome); - check_http_status_for_error(&outcome.response)?; - AdResponse::::parse(outcome.response.json()?, &self.telemetry)? + let (response, cache_outcomes) = cache.send_with_policy(ad_request, cache_policy)?; + for outcome in &cache_outcomes { + self.telemetry.record(outcome); + } + check_http_status_for_error(&response)?; + AdResponse::::parse(response.json()?, &self.telemetry)? } else { let request: Request = ad_request.into(); let response = request.send()?; diff --git a/components/ads-client/tests/integration_test.rs b/components/ads-client/tests/integration_test.rs deleted file mode 100644 index b73d8fa542..0000000000 --- a/components/ads-client/tests/integration_test.rs +++ /dev/null @@ -1,175 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public -* License, v. 2.0. If a copy of the MPL was not distributed with this -* file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -use std::hash::{Hash, Hasher}; -use std::time::Duration; - -use ads_client::{ - http_cache::{ByteSize, CacheOutcome, HttpCache, RequestCachePolicy}, - MozAdsClientBuilder, MozAdsPlacementRequest, MozAdsPlacementRequestWithCount, -}; -use url::Url; -use viaduct::Request; - -/// Test-only hashable wrapper around Request. -#[derive(Clone)] -struct TestRequest(Request); - -impl Hash for TestRequest { - fn hash(&self, state: &mut H) { - self.0.method.as_str().hash(state); - self.0.url.as_str().hash(state); - } -} - -impl From for Request { - fn from(t: TestRequest) -> Self { - t.0 - } -} - -#[test] -#[ignore] -fn test_mock_pocket_billboard_1_placement() { - viaduct_dev::init_backend_dev(); - - let client = MozAdsClientBuilder::new().build(); - - let placement_request = MozAdsPlacementRequest { - placement_id: "mock_pocket_billboard_1".to_string(), - iab_content: None, - }; - - let result = client.request_image_ads(vec![placement_request], None); - - assert!(result.is_ok(), "Failed to request ads: {:?}", result.err()); - - let placements = result.unwrap(); - - assert!( - placements.contains_key("mock_pocket_billboard_1"), - "Response should contain placement_id 'mock_pocket_billboard_1'" - ); - - placements - .get("mock_pocket_billboard_1") - .expect("Placement should exist"); -} - -#[test] -#[ignore] -fn test_newtab_spocs_placement() { - viaduct_dev::init_backend_dev(); - - let client = MozAdsClientBuilder::new().build(); - - let count = 3; - let placement_request = MozAdsPlacementRequestWithCount { - placement_id: "newtab_spocs".to_string(), - count, - iab_content: None, - }; - - let result = client.request_spoc_ads(vec![placement_request], None); - - assert!(result.is_ok(), "Failed to request ads: {:?}", result.err()); - - let placements = result.unwrap(); - - assert!( - placements.contains_key("newtab_spocs"), - "Response should contain placement_id 'newtab_spocs'" - ); - - let spocs = placements - .get("newtab_spocs") - .expect("Placement should exist"); - - assert_eq!( - spocs.len(), - count as usize, - "Number of spocs should equal count parameter" - ); -} - -#[test] -#[ignore] -fn test_newtab_tile_1_placement() { - viaduct_dev::init_backend_dev(); - - let client = MozAdsClientBuilder::new().build(); - - let placement_request = MozAdsPlacementRequest { - placement_id: "newtab_tile_1".to_string(), - iab_content: None, - }; - - let result = client.request_tile_ads(vec![placement_request], None); - - assert!(result.is_ok(), "Failed to request ads: {:?}", result.err()); - - let placements = result.unwrap(); - - assert!( - placements.contains_key("newtab_tile_1"), - "Response should contain placement_id 'newtab_tile_1'" - ); - - placements - .get("newtab_tile_1") - .expect("Placement should exist"); -} - -#[test] -#[ignore] -fn test_cache_works_using_real_timeouts() { - viaduct_dev::init_backend_dev(); - - let cache: HttpCache = HttpCache::builder("integration_tests.db") - .default_ttl(Duration::from_secs(60)) - .max_size(ByteSize::mib(1)) - .build() - .expect("cache build should succeed"); - - let url = Url::parse("https://ads.mozilla.org/v1/ads").unwrap(); - let req = TestRequest(Request::post(url).json(&serde_json::json!({ - "context_id": "12347fff-00b0-aaaa-0978-189231239808", - "placements": [ - { - "placement": "mock_pocket_billboard_1", - "count": 1, - } - ], - }))); - - let test_ttl = 2; - - // First call: miss -> store - let o1 = cache - .send_with_policy( - req.clone(), - &RequestCachePolicy { - mode: ads_client::http_cache::CacheMode::CacheFirst, - ttl_seconds: Some(test_ttl), - }, - ) - .unwrap(); - matches!(o1.cache_outcome, CacheOutcome::MissStored); - - // Second call: hit (no extra HTTP) but no refresh - let o2 = cache - .send_with_policy(req.clone(), &RequestCachePolicy::default()) - .unwrap(); - matches!(o2.cache_outcome, CacheOutcome::Hit); - assert_eq!(o2.response.status, 200); - - // Third call: Miss due to timeout for the test_ttl duration - std::thread::sleep(Duration::from_secs(test_ttl)); - let o3 = cache - .send_with_policy(req, &RequestCachePolicy::default()) - .unwrap(); - matches!(o3.cache_outcome, CacheOutcome::MissStored); - assert_eq!(o3.response.status, 200); -}