Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
use std::hash::{Hash, Hasher};
use std::time::Duration;

use ads_client::http_cache::{ByteSize, CacheOutcome, CachePolicy, HttpCache};
use ads_client::database::bytesize::ByteSize;
use ads_client::http_cache::{CacheOutcome, CachePolicy, HttpCache};
use mockito::mock;
use viaduct::{Client, ClientSettings, Request};

Expand Down
150 changes: 150 additions & 0 deletions components/ads-client/src/ads_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
pub mod builder;
pub mod connection_initializer;
pub mod store;

use crate::{
ads_store::{builder::AdsStoreBuilder, store::AdsStoreHolder},
database::bytesize::ByteSize,
};
use std::path::Path;

/// Identification of placement sent and returned from MARS (eg: `mock_spoc_1`)
#[derive(Debug, Hash, PartialEq, Eq, Clone)]
pub struct PlacementId(String);

impl PlacementId {
pub fn new(s: &str) -> PlacementId {
PlacementId(s.to_string())
}
pub fn into_inner(self) -> String {
self.0
}
}

impl AsRef<str> for PlacementId {
fn as_ref(&self) -> &str {
&self.0
}
}

pub struct AdsStore {
#[allow(dead_code)]
max_size: ByteSize,
holder: AdsStoreHolder,
}

impl AdsStore {
pub fn builder<P: AsRef<Path>>(db_path: P) -> AdsStoreBuilder {
AdsStoreBuilder::new(db_path.as_ref())
}

pub fn clear(&self) -> Result<(), rusqlite::Error> {
self.holder.clear_all()?;
Ok(())
}

pub fn shutdown_db(self) -> Result<(), rusqlite::Error> {
self.holder.close()
}

pub fn invalidate_by_id(&self, placement_id: &PlacementId) -> Result<(), rusqlite::Error> {
self.holder.invalidate_ad_by_id(placement_id)?;
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::mars::ad_response::{AdCallbacks, AdImage, StorableAd, StorableAdType};
use url::Url;

#[test]
fn test_ads_store_creation() {
// Test that AdsStore can be created successfully with test config
let store: Result<AdsStore, _> = AdsStore::builder("test_store.db").build();
assert!(store.is_ok());
}

#[test]
fn test_clear_store() {
let store: AdsStore = AdsStore::builder("test_clear.db").build().unwrap();

// Create a test request and response
let base_url = mockito::server_url();
let ad = AdImage {
url: "https://ads.fakeexample.org/example_ad_1".to_string(),
image_url: "https://ads.fakeexample.org/example_image_1".to_string(),
format: "billboard".to_string(),
block_key: "abc123".into(),
alt_text: Some("An ad for a puppy".to_string()),
callbacks: AdCallbacks {
click: Url::parse(&format!("{}/click/example_ad_1", base_url)).unwrap(),
impression: Url::parse(&format!("{}/impression/example_ad_1", base_url)).unwrap(),
report: Some(Url::parse(&format!("{}/report/example_ad_1", base_url)).unwrap()),
},
};

let ad = StorableAd {
placement_id: PlacementId::new("mock_billboard_1"),
ad_type: StorableAdType::Image,
ad_body: serde_json::to_vec(&ad).unwrap(),
};

store.holder.store_ad(ad.clone()).unwrap();

// Verify it's cached
let retrieved = store.holder.lookup(&ad.placement_id).unwrap();
assert!(retrieved.is_some());

// Clear the cache
store.clear().unwrap();

// Verify it's cleared
let retrieved_after_clear = store.holder.lookup(&ad.placement_id).unwrap();
assert!(retrieved_after_clear.is_none());
}

#[test]
fn test_invalidate_by_hash() {
let store: AdsStore = AdsStore::builder("test_invalidate.db").build().unwrap();

// Create a test request and response
let base_url = mockito::server_url();
let ad = AdImage {
url: "https://ads.fakeexample.org/example_ad_1".to_string(),
image_url: "https://ads.fakeexample.org/example_image_1".to_string(),
format: "billboard".to_string(),
block_key: "abc123".into(),
alt_text: Some("An ad for a puppy".to_string()),
callbacks: AdCallbacks {
click: Url::parse(&format!("{}/click/example_ad_1", base_url)).unwrap(),
impression: Url::parse(&format!("{}/impression/example_ad_1", base_url)).unwrap(),
report: Some(Url::parse(&format!("{}/report/example_ad_1", base_url)).unwrap()),
},
};

let ad_1 = StorableAd {
placement_id: PlacementId::new("mock_billboard_1"),
ad_type: StorableAdType::Image,
ad_body: serde_json::to_vec(&ad).unwrap(),
};
let ad_2 = StorableAd {
placement_id: PlacementId::new("mock_billboard_2"),
ad_type: StorableAdType::Image,
ad_body: serde_json::to_vec(&ad).unwrap(),
};

store.holder.store_ad(ad_1.clone()).unwrap();

store.holder.store_ad(ad_2.clone()).unwrap();

assert!(store.holder.lookup(&ad_1.placement_id).unwrap().is_some());
assert!(store.holder.lookup(&ad_2.placement_id).unwrap().is_some());

store.invalidate_by_id(&ad_1.placement_id).unwrap();

assert!(store.holder.lookup(&ad_1.placement_id).unwrap().is_none());
assert!(store.holder.lookup(&ad_2.placement_id).unwrap().is_some());
}
}
169 changes: 169 additions & 0 deletions components/ads-client/src/ads_store/builder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* 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 super::connection_initializer::HttpCacheConnectionInitializer;
use crate::ads_store::store::AdsStoreHolder;
use crate::ads_store::AdsStore;
use crate::database::bytesize::ByteSize;
use rusqlite::Connection;
use sql_support::open_database;
use std::path::PathBuf;

const DEFAULT_MAX_SIZE: ByteSize = ByteSize::mib(10);
const MIN_STORE_SIZE: ByteSize = ByteSize::kib(1);
const MAX_STORE_SIZE: ByteSize = ByteSize::mib(100);

#[derive(Debug, thiserror::Error)]
pub enum AdsStoreBuilderError {
#[error("Database path cannot be empty")]
EmptyDbPath,
#[error("Database error: {0}")]
Database(#[from] open_database::Error),
#[error(
"Maximum store size must be between {min_size} and {max_size}, got {size_bytes} bytes"
)]
InvalidMaxSize {
max_size: String,
min_size: String,
size_bytes: u64,
},
}

pub struct AdsStoreBuilder {
db_path: PathBuf,
max_size: Option<ByteSize>,
}

impl AdsStoreBuilder {
pub fn new(db_path: impl Into<PathBuf>) -> Self {
Self {
db_path: db_path.into(),
max_size: None,
}
}

pub fn max_size(mut self, max_size: ByteSize) -> Self {
self.max_size = Some(max_size);
self
}

fn open_connection(&self) -> Result<Connection, AdsStoreBuilderError> {
let initializer = HttpCacheConnectionInitializer {};
let conn = if cfg!(test) {
open_database::open_memory_database(&initializer)?
} else {
open_database::open_database(&self.db_path, &initializer)?
};
Ok(conn)
}

fn validate(&self) -> Result<(), AdsStoreBuilderError> {
if self.db_path.to_string_lossy().trim().is_empty() {
return Err(AdsStoreBuilderError::EmptyDbPath);
}

if let Some(max_size) = self.max_size {
if max_size < MIN_STORE_SIZE || max_size > MAX_STORE_SIZE {
return Err(AdsStoreBuilderError::InvalidMaxSize {
size_bytes: max_size.as_u64(),
min_size: MIN_STORE_SIZE.to_string(),
max_size: MAX_STORE_SIZE.to_string(),
});
}
}

Ok(())
}

pub fn build(&self) -> Result<AdsStore, AdsStoreBuilderError> {
self.validate()?;

let conn = self.open_connection()?;
let holder = AdsStoreHolder::new(conn);
let max_size = self.max_size.unwrap_or(DEFAULT_MAX_SIZE);
Ok(AdsStore { max_size, holder })
}

#[cfg(test)]
pub fn build_for_time_dependent_tests(&self) -> Result<AdsStore, AdsStoreBuilderError> {
self.validate()?;

let conn = self.open_connection()?;
let max_size = DEFAULT_MAX_SIZE;
let holder = AdsStoreHolder::new_with_test_clock(conn);

Ok(AdsStore { max_size, holder })
}
}

#[cfg(test)]
mod tests {
use super::*;

fn make_test_builder(path: &str) -> AdsStoreBuilder {
AdsStoreBuilder::new(path)
}

#[test]
fn test_store_builder_with_defaults() {
let builder = make_test_builder("test.db");
assert_eq!(builder.db_path, PathBuf::from("test.db"));
assert_eq!(builder.max_size, None);
assert!(builder.build().is_ok());
}

#[test]
fn test_cache_builder_valid_custom() {
let builder = make_test_builder("custom.db").max_size(ByteSize::b(1024));

assert_eq!(builder.db_path, PathBuf::from("custom.db"));
assert_eq!(builder.max_size, Some(ByteSize::b(1024)));
assert!(builder.build().is_ok());
}

#[test]
fn test_validation_empty_db_path() {
let result = make_test_builder(" ").build();
assert!(matches!(result, Err(AdsStoreBuilderError::EmptyDbPath)));
}

#[test]
fn test_validation_max_size_too_small() {
let result = make_test_builder("test.db")
.max_size(ByteSize::b(512))
.build();
assert!(matches!(
result,
Err(AdsStoreBuilderError::InvalidMaxSize {
size_bytes: 512,
min_size: _,
max_size: _,
})
));
}

#[test]
fn test_validation_max_size_too_large() {
let result = make_test_builder("test.db")
.max_size(ByteSize::b(2 * 1024 * 1024 * 1024))
.build();
assert!(matches!(
result,
Err(AdsStoreBuilderError::InvalidMaxSize {
size_bytes: 2147483648,
min_size: _,
max_size: _,
})
));
}

#[test]
fn test_validation_max_size_boundaries() {
let builder_min = make_test_builder("test.db").max_size(MIN_STORE_SIZE);
assert!(builder_min.build().is_ok());

let builder_max = make_test_builder("test.db").max_size(MAX_STORE_SIZE);
assert!(builder_max.build().is_ok());
}
}
Loading
Loading