From a281e4b0f623a98ddd4d823fdd7a491b823f5f61 Mon Sep 17 00:00:00 2001 From: ahanot Date: Tue, 2 Jun 2026 13:24:32 -0400 Subject: [PATCH] feat(ads-client): support content categories on ad requests (AC-109) Add per-placement IAB content (taxonomy + category IDs) and a request-level `flags` object plumbed through MARSClient/AdsClient. Callers opt into flags via `MozAdsRequestOptions.flags: MozAdsRequestFlags?` (currently exposing `contextualPlacement`), with room to grow as new flags are added. The `flags` object is included in the cache key only when at least one flag opts away from its default, so non-contextual callers keep their existing cache entries. --- CHANGELOG.md | 5 + .../ads-client/docs/usage-javascript.md | 10 +- components/ads-client/docs/usage-kotlin.md | 10 +- components/ads-client/docs/usage-swift.md | 10 +- .../integration-tests/tests/mars.rs | 38 +++- components/ads-client/src/client.rs | 57 ++++-- components/ads-client/src/ffi.rs | 19 +- components/ads-client/src/lib.rs | 20 +- components/ads-client/src/mars.rs | 9 +- components/ads-client/src/mars/ad_request.rs | 172 +++++++++++++++++- 10 files changed, 304 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fa08ab44..c84a4016e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ [Full Changelog](In progress) +## ✨ What's New ✨ + +### Ads Client +- Add support for IAB content categories and the `flags.contextual_placement` request flag on ad requests. Callers can attach an `iabContent` (taxonomy + category IDs) to each `MozAdsPlacementRequest`, and set `contextualPlacement: true` on `MozAdsRequestOptions` to opt the request into contextual placement on the server side (AC-109). + # v152.0 (_2026-05-18_) ## ⚠️ Breaking Changes ⚠️ diff --git a/components/ads-client/docs/usage-javascript.md b/components/ads-client/docs/usage-javascript.md index 41edf2abaa..50403f7007 100644 --- a/components/ads-client/docs/usage-javascript.md +++ b/components/ads-client/docs/usage-javascript.md @@ -246,14 +246,16 @@ Options passed when making a single ad request. /** * @typedef {Object} MozAdsRequestOptions * @property {MozAdsRequestCachePolicy|null} cachePolicy - Per-request caching policy. + * @property {Object.} flags - Request-level flags forwarded as the `flags` object on the wire. An empty object omits it. * @property {boolean} ohttp - Whether to route this request through OHTTP (default: false). */ ``` -| Field | Type | Description | -| -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | -| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| Field | Type | Description | +| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | +| `flags` | `Object.` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty object omits it. e.g. `{ contextual_placement: true }`. Defaults to `{}`. | +| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. | --- diff --git a/components/ads-client/docs/usage-kotlin.md b/components/ads-client/docs/usage-kotlin.md index 2ccb1ec0cf..a7fdc89228 100644 --- a/components/ads-client/docs/usage-kotlin.md +++ b/components/ads-client/docs/usage-kotlin.md @@ -220,14 +220,16 @@ Options passed when making a single ad request. ```kotlin data class MozAdsRequestOptions( val cachePolicy: MozAdsRequestCachePolicy?, + val flags: Map = emptyMap(), val ohttp: Boolean = false ) ``` -| Field | Type | Description | -| -------------- | ---------------------------- | ---------------------------------------------------------------------------------------------- | -| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | -| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| Field | Type | Description | +| ------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | +| `flags` | `Map` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty map omits it. e.g. `mapOf("contextual_placement" to true)`. Defaults to `emptyMap()`. | +| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. | --- diff --git a/components/ads-client/docs/usage-swift.md b/components/ads-client/docs/usage-swift.md index e6b4a66d42..59a91d7a8d 100644 --- a/components/ads-client/docs/usage-swift.md +++ b/components/ads-client/docs/usage-swift.md @@ -220,14 +220,16 @@ Options passed when making a single ad request. ```swift struct MozAdsRequestOptions { let cachePolicy: MozAdsRequestCachePolicy? + let flags: [String: Bool] // default: [:] let ohttp: Bool // default: false } ``` -| Field | Type | Description | -| -------------- | ----------------------------- | --------------------------------------------------------------------------------------------- | -| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. | -| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. | +| Field | Type | Description | +| ------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. | +| `flags` | `[String: Bool]` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty dictionary omits it. e.g. `["contextual_placement": true]`. Defaults to `[:]`. | +| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. | --- diff --git a/components/ads-client/integration-tests/tests/mars.rs b/components/ads-client/integration-tests/tests/mars.rs index 7abf83282b..b43921310d 100644 --- a/components/ads-client/integration-tests/tests/mars.rs +++ b/components/ads-client/integration-tests/tests/mars.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use ads_client::{ - MozAdsClientBuilder, MozAdsEnvironment, MozAdsPlacementRequest, - MozAdsPlacementRequestWithCount, MozAdsReportReason, MozAdsRequestOptions, + MozAdsClientBuilder, MozAdsEnvironment, MozAdsIABContent, MozAdsIABContentTaxonomy, + MozAdsPlacementRequest, MozAdsPlacementRequestWithCount, MozAdsReportReason, + MozAdsRequestOptions, }; fn init_backend() { @@ -44,6 +45,39 @@ fn test_contract_image_prod() { assert!(placements.contains_key("mock_billboard_1")); } +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_image_with_categories_prod() { + init_backend(); + + let client = prod_client(); + let result = client.request_image_ads( + vec![MozAdsPlacementRequest { + iab_content: Some(MozAdsIABContent { + category_ids: vec!["338".to_string()], + taxonomy: MozAdsIABContentTaxonomy::IAB3_0, + }), + placement_id: "mock_billboard_1".to_string(), + }], + Some(MozAdsRequestOptions { + flags: std::collections::HashMap::from([("contextual_placement".to_string(), true)]), + ..Default::default() + }), + ); + + assert!( + result.is_ok(), + "Image ad request with categories failed: {:?}", + result.err() + ); + let placements = result.unwrap(); + let ad = placements + .get("mock_billboard_1") + .expect("mock_billboard_1 should be present in the response"); + assert!(!ad.url.is_empty(), "destination url should be populated"); + assert!(!ad.image_url.is_empty(), "image url should be populated"); +} + #[test] #[ignore = "integration test: run manually with -- --ignored"] fn test_contract_spoc_prod() { diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 08dbe2750c..e9a2ab0c72 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::time::Duration; use crate::http_cache::{ByteSize, CachePolicy, HttpCache}; -use crate::mars::ad_request::AdPlacementRequest; +use crate::mars::ad_request::{AdPlacementRequest, AdRequestFlags}; use crate::mars::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile}; use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError}; use crate::mars::{MARSClient, ReportReason}; @@ -160,11 +160,12 @@ where pub fn request_image_ads( &self, ad_placement_requests: Vec, + flags: AdRequestFlags, options: Option, ohttp: bool, ) -> Result, RequestAdsError> { let response = self - .request_ads::(ad_placement_requests, options, ohttp) + .request_ads::(ad_placement_requests, flags, options, ohttp) .inspect_err(|e| { self.telemetry.record(e); })?; @@ -175,10 +176,11 @@ where pub fn request_spoc_ads( &self, ad_placement_requests: Vec, + flags: AdRequestFlags, options: Option, ohttp: bool, ) -> Result>, RequestAdsError> { - let result = self.request_ads::(ad_placement_requests, options, ohttp); + let result = self.request_ads::(ad_placement_requests, flags, options, ohttp); result .inspect_err(|e| { self.telemetry.record(e); @@ -192,10 +194,11 @@ where pub fn request_tile_ads( &self, ad_placement_requests: Vec, + flags: AdRequestFlags, options: Option, ohttp: bool, ) -> Result, RequestAdsError> { - let result = self.request_ads::(ad_placement_requests, options, ohttp); + let result = self.request_ads::(ad_placement_requests, flags, options, ohttp); result .inspect_err(|e| { self.telemetry.record(e); @@ -209,6 +212,7 @@ where fn request_ads( &self, placements: Vec, + flags: AdRequestFlags, options: Option, ohttp: bool, ) -> Result, RequestAdsError> @@ -219,7 +223,7 @@ where let cache_policy = options.unwrap_or_default(); let (mut response, request_hash) = self.client - .fetch_ads::(context_id, placements, cache_policy, ohttp)?; + .fetch_ads::(context_id, flags, placements, cache_policy, ohttp)?; response.enrich_callbacks(&request_hash); Ok(response) } @@ -289,7 +293,12 @@ mod tests { let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let result = ads_client.request_image_ads(make_happy_placement_requests(), None, false); + let result = ads_client.request_image_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ); assert!(result.is_ok()); m.assert(); } @@ -308,7 +317,12 @@ mod tests { let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let result = ads_client.request_spoc_ads(make_happy_placement_requests(), None, false); + let result = ads_client.request_spoc_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ); assert!(result.is_ok()); m.assert(); } @@ -327,7 +341,12 @@ mod tests { let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let result = ads_client.request_tile_ads(make_happy_placement_requests(), None, false); + let result = ads_client.request_tile_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ); assert!(result.is_ok()); m.assert(); } @@ -363,7 +382,12 @@ mod tests { assert_eq!(client.get_context_id().unwrap(), "custom-context-id-12345"); - let result = client.request_image_ads(make_happy_placement_requests(), None, false); + let result = client.request_image_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ); assert!(result.is_ok()); m.assert(); } @@ -392,7 +416,12 @@ mod tests { .create(); let response = ads_client - .request_image_ads(make_happy_placement_requests(), None, false) + .request_image_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ) .unwrap(); let callback_url = response.values().next().unwrap().callbacks.click.clone(); @@ -401,7 +430,12 @@ mod tests { .create(); ads_client - .request_image_ads(make_happy_placement_requests(), None, false) + .request_image_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + ) .unwrap(); ads_client.record_click(callback_url, false).unwrap(); @@ -409,6 +443,7 @@ mod tests { ads_client .request_ads::( make_happy_placement_requests(), + AdRequestFlags::default(), Some(CachePolicy::default()), false, ) diff --git a/components/ads-client/src/ffi.rs b/components/ads-client/src/ffi.rs index a6867514b5..cffa83bd60 100644 --- a/components/ads-client/src/ffi.rs +++ b/components/ads-client/src/ffi.rs @@ -12,7 +12,9 @@ use crate::client::config::{AdsCacheConfig, AdsClientConfig}; use crate::client::{AdsClient, ContextIdProvider}; use crate::ffi::telemetry::MozAdsTelemetryWrapper; use crate::http_cache::CachePolicy; -use crate::mars::ad_request::{AdContentCategory, AdPlacementRequest, IABContentTaxonomy}; +use crate::mars::ad_request::{ + AdContentCategory, AdPlacementRequest, AdRequestFlags, IABContentTaxonomy, +}; use crate::mars::ad_response::{ AdCallbacks, AdImage, AdSpoc, AdTile, SpocFrequencyCaps, SpocRanking, }; @@ -20,6 +22,7 @@ use crate::mars::Environment; use crate::mars::ReportReason; use crate::MozAdsClient; use parking_lot::Mutex; +use std::collections::HashMap; use url::Url; pub use error::{AdsClientApiResult, MozAdsClientApiError}; @@ -55,6 +58,8 @@ impl From for Box { #[derive(Default, uniffi::Record)] pub struct MozAdsRequestOptions { pub cache_policy: Option, + #[uniffi(default)] + pub flags: HashMap, #[uniffi(default = false)] pub ohttp: bool, } @@ -425,15 +430,15 @@ impl From<&MozAdsIABContent> for AdContentCategory { } } -impl From for CachePolicy { - fn from(options: MozAdsRequestOptions) -> Self { - options.cache_policy.map(Into::into).unwrap_or_default() +impl From<&MozAdsRequestOptions> for AdRequestFlags { + fn from(options: &MozAdsRequestOptions) -> Self { + options.flags.clone() } } -impl From> for CachePolicy { - fn from(options: Option) -> Self { - options.map(Into::into).unwrap_or_default() +impl From for CachePolicy { + fn from(options: MozAdsRequestOptions) -> Self { + options.cache_policy.map(Into::into).unwrap_or_default() } } diff --git a/components/ads-client/src/lib.rs b/components/ads-client/src/lib.rs index 59bde082b1..2088ee16cb 100644 --- a/components/ads-client/src/lib.rs +++ b/components/ads-client/src/lib.rs @@ -13,7 +13,7 @@ use url::Url as AdsClientUrl; use client::AdsClient; use http_cache::CachePolicy; -use mars::ad_request::AdPlacementRequest; +use mars::ad_request::{AdPlacementRequest, AdRequestFlags}; mod client; mod ffi; @@ -111,10 +111,12 @@ impl MozAdsClient { ) -> AdsClientApiResult> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); - let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); + let options = options.unwrap_or_default(); + let flags = AdRequestFlags::from(&options); + let ohttp = options.ohttp; let cache_policy: CachePolicy = options.into(); let response = inner - .request_image_ads(requests, Some(cache_policy), ohttp) + .request_image_ads(requests, flags, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response.into_iter().map(|(k, v)| (k, v.into())).collect()) } @@ -128,10 +130,12 @@ impl MozAdsClient { ) -> AdsClientApiResult>> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); - let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); + let options = options.unwrap_or_default(); + let flags = AdRequestFlags::from(&options); + let ohttp = options.ohttp; let cache_policy: CachePolicy = options.into(); let response = inner - .request_spoc_ads(requests, Some(cache_policy), ohttp) + .request_spoc_ads(requests, flags, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response .into_iter() @@ -148,10 +152,12 @@ impl MozAdsClient { ) -> AdsClientApiResult> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); - let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); + let options = options.unwrap_or_default(); + let flags = AdRequestFlags::from(&options); + let ohttp = options.ohttp; let cache_policy: CachePolicy = options.into(); let response = inner - .request_tile_ads(requests, Some(cache_policy), ohttp) + .request_tile_ads(requests, flags, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response.into_iter().map(|(k, v)| (k, v.into())).collect()) } diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index 37233ac40f..59e044212b 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -15,7 +15,7 @@ pub use environment::Environment; pub use report_reason::ReportReason; use self::{ - ad_request::{AdPlacementRequest, AdRequest}, + ad_request::{AdPlacementRequest, AdRequest, AdRequestFlags}, ad_response::{AdResponse, AdResponseValue}, error::{ CallbackRequestError, FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError, @@ -60,6 +60,7 @@ where pub fn fetch_ads( &self, context_id: String, + flags: AdRequestFlags, placements: Vec, cache_policy: CachePolicy, ohttp: bool, @@ -67,7 +68,8 @@ where where A: AdResponseValue, { - let mut ad_request = AdRequest::try_new(context_id, self.environment, ohttp, placements)?; + let mut ad_request = + AdRequest::try_new(context_id, self.environment, flags, ohttp, placements)?; let request_hash = RequestHash::new(&ad_request); if ohttp { @@ -224,6 +226,7 @@ mod tests { let result = client.fetch_ads::( TEST_CONTEXT_ID.to_string(), + AdRequestFlags::default(), make_happy_placement_requests(), CachePolicy::default(), false, @@ -256,6 +259,7 @@ mod tests { let (response1, _) = client .fetch_ads::( TEST_CONTEXT_ID.to_string(), + AdRequestFlags::default(), make_happy_placement_requests(), CachePolicy::default(), false, @@ -267,6 +271,7 @@ mod tests { let (response2, _) = client .fetch_ads::( TEST_CONTEXT_ID.to_string(), + AdRequestFlags::default(), make_happy_placement_requests(), CachePolicy::default(), false, diff --git a/components/ads-client/src/mars/ad_request.rs b/components/ads-client/src/mars/ad_request.rs index 105ad11ef4..22cf0361ae 100644 --- a/components/ads-client/src/mars/ad_request.rs +++ b/components/ads-client/src/mars/ad_request.rs @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; @@ -21,6 +21,8 @@ pub struct AdRequest { /// Skipped to exclude from the request body #[serde(skip)] pub environment: Environment, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub flags: AdRequestFlags, #[serde(skip)] pub headers: Headers, #[serde(skip)] @@ -31,11 +33,21 @@ pub struct AdRequest { /// Hash implementation intentionally excludes `context_id` as it rotates /// on client re-instantiation and should not invalidate cached responses. /// `headers` are also excluded as they are request metadata, not cache keys. +/// `flags` is hashed only when set so non-flag callers keep prior cache keys. /// If response shape ever varies, add a version to this hash for variant tracking. impl Hash for AdRequest { fn hash(&self, state: &mut H) { ENDPOINT.hash(state); self.environment.hash(state); + if !self.flags.is_empty() { + // HashMap is unordered — sort by key for a stable hash. + let mut sorted: Vec<_> = self.flags.iter().collect(); + sorted.sort_unstable_by_key(|(k, _)| k.as_str()); + for (k, v) in sorted { + k.hash(state); + v.hash(state); + } + } self.ohttp.hash(state); self.placements.hash(state); } @@ -54,6 +66,7 @@ impl AdRequest { pub fn try_new( context_id: String, environment: Environment, + flags: AdRequestFlags, ohttp: bool, placements: Vec, ) -> Result { @@ -64,6 +77,7 @@ impl AdRequest { let mut request = AdRequest { context_id, environment, + flags, headers: Headers::new(), ohttp, placements: vec![], @@ -96,6 +110,8 @@ impl AdRequest { } } +pub type AdRequestFlags = HashMap; + #[derive(Debug, Hash, PartialEq, Serialize)] pub struct AdPlacementRequest { pub content: Option, @@ -185,6 +201,7 @@ mod tests { let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), Environment::Test, + HashMap::from([("contextual_placement".to_string(), true)]), false, vec![ AdPlacementRequest { @@ -210,6 +227,7 @@ mod tests { let expected_request = AdRequest { context_id: TEST_CONTEXT_ID.to_string(), environment: Environment::Test, + flags: HashMap::from([("contextual_placement".to_string(), true)]), headers: Headers::new(), ohttp: false, placements: vec![ @@ -235,11 +253,137 @@ mod tests { assert_eq!(request, expected_request); } + #[test] + fn test_ad_request_omits_flags_when_none_are_set() { + let request = AdRequest::try_new( + TEST_CONTEXT_ID.to_string(), + Environment::Test, + AdRequestFlags::default(), + false, + vec![AdPlacementRequest { + content: None, + count: 1, + placement: "example_placement".to_string(), + }], + ) + .unwrap(); + + assert!(request.flags.is_empty()); + + let serialized = to_value(&request).unwrap(); + assert!( + serialized.get("flags").is_none(), + "flags object must be omitted from the wire when no flag is set, got: {serialized}" + ); + } + + #[test] + fn test_ad_request_serializes_explicit_false_flag() { + let request = AdRequest::try_new( + TEST_CONTEXT_ID.to_string(), + Environment::Test, + HashMap::from([("contextual_placement".to_string(), false)]), + false, + vec![AdPlacementRequest { + content: None, + count: 1, + placement: "example_placement".to_string(), + }], + ) + .unwrap(); + + let serialized = to_value(&request).unwrap(); + assert_eq!( + serialized.get("flags"), + Some(&json!({"contextual_placement": false})), + "Some(false) must round-trip onto the wire so callers can express explicit false", + ); + } + + #[test] + fn test_ad_request_serializes_with_contextual_placement_flag_and_mixed_content() { + let request = AdRequest::try_new( + "context-123".to_string(), + Environment::Test, + HashMap::from([("contextual_placement".to_string(), true)]), + false, + vec![ + AdPlacementRequest { + content: None, + count: 1, + placement: "newtab_stories_v2_1".to_string(), + }, + AdPlacementRequest { + content: Some(AdContentCategory { + categories: vec!["338".to_string()], + taxonomy: IABContentTaxonomy::IAB3_0, + }), + count: 1, + placement: "newtab_stories_v2_3".to_string(), + }, + AdPlacementRequest { + content: Some(AdContentCategory { + categories: vec!["596".to_string()], + taxonomy: IABContentTaxonomy::IAB3_0, + }), + count: 1, + placement: "newtab_stories_v2_4".to_string(), + }, + ], + ) + .unwrap(); + + let serialized = to_value(&request).unwrap(); + let expected_json = json!({ + "context_id": "context-123", + "flags": {"contextual_placement": true}, + "placements": [ + {"placement": "newtab_stories_v2_1", "count": 1, "content": null}, + {"placement": "newtab_stories_v2_3", "count": 1, "content": {"taxonomy": "IAB-3.0", "categories": ["338"]}}, + {"placement": "newtab_stories_v2_4", "count": 1, "content": {"taxonomy": "IAB-3.0", "categories": ["596"]}}, + ], + }); + assert_eq!(serialized, expected_json); + } + + #[test] + fn test_contextual_placement_flag_produces_different_hash() { + use crate::http_cache::RequestHash; + + let make_placements = || { + vec![AdPlacementRequest { + content: None, + count: 1, + placement: "tile_1".to_string(), + }] + }; + + let req_off = AdRequest::try_new( + "same-id".to_string(), + Environment::Test, + AdRequestFlags::default(), + false, + make_placements(), + ) + .unwrap(); + let req_on = AdRequest::try_new( + "same-id".to_string(), + Environment::Test, + HashMap::from([("contextual_placement".to_string(), true)]), + false, + make_placements(), + ) + .unwrap(); + + assert_ne!(RequestHash::new(&req_off), RequestHash::new(&req_on)); + } + #[test] fn test_build_ad_request_fails_on_duplicate_placement_id() { let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), Environment::Test, + AdRequestFlags::default(), false, vec![ AdPlacementRequest { @@ -268,6 +412,7 @@ mod tests { let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), Environment::Test, + AdRequestFlags::default(), false, vec![], ); @@ -289,10 +434,22 @@ mod tests { let context_id_a = "aaaa-bbbb-cccc".to_string(); let context_id_b = "dddd-eeee-ffff".to_string(); - let req1 = - AdRequest::try_new(context_id_a, Environment::Test, false, make_placements()).unwrap(); - let req2 = - AdRequest::try_new(context_id_b, Environment::Test, false, make_placements()).unwrap(); + let req1 = AdRequest::try_new( + context_id_a, + Environment::Test, + AdRequestFlags::default(), + false, + make_placements(), + ) + .unwrap(); + let req2 = AdRequest::try_new( + context_id_b, + Environment::Test, + AdRequestFlags::default(), + false, + make_placements(), + ) + .unwrap(); assert_eq!(RequestHash::new(&req1), RequestHash::new(&req2)); } @@ -304,6 +461,7 @@ mod tests { let req1 = AdRequest::try_new( "same-id".to_string(), Environment::Test, + AdRequestFlags::default(), false, vec![AdPlacementRequest { content: None, @@ -316,6 +474,7 @@ mod tests { let req2 = AdRequest::try_new( "same-id".to_string(), Environment::Test, + AdRequestFlags::default(), false, vec![AdPlacementRequest { content: None, @@ -343,6 +502,7 @@ mod tests { let req_direct = AdRequest::try_new( "same-id".to_string(), Environment::Test, + AdRequestFlags::default(), false, make_placements(), ) @@ -350,6 +510,7 @@ mod tests { let req_ohttp = AdRequest::try_new( "same-id".to_string(), Environment::Test, + AdRequestFlags::default(), true, make_placements(), ) @@ -365,6 +526,7 @@ mod tests { let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), Environment::Test, + AdRequestFlags::default(), false, vec![AdPlacementRequest { content: None,