#![allow(deprecated)]
use httpmock::prelude::*;
#[cfg(feature = "async-client")]
use posthog_rs::AsyncFlagPoller;
use posthog_rs::{
ClientOptionsBuilder, FeatureFlag, FeatureFlagCondition, FeatureFlagFilters, FlagCache,
FlagPoller, FlagValue, LocalEvaluationConfig, LocalEvaluationResponse, LocalEvaluator,
Property,
};
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
#[test]
fn test_local_evaluation_basic() {
let cache = FlagCache::new();
let evaluator = LocalEvaluator::new(cache.clone());
let flag = FeatureFlag {
key: "test-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let response = LocalEvaluationResponse {
flags: vec![flag],
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
let properties = HashMap::new();
let result = evaluator.evaluate_flag(
"test-flag",
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(FlagValue::Boolean(true)));
}
#[test]
fn test_local_evaluation_with_properties() {
let cache = FlagCache::new();
let evaluator = LocalEvaluator::new(cache.clone());
let flag = FeatureFlag {
key: "premium-feature".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![Property {
key: "plan".to_string(),
value: json!("premium"),
operator: "exact".to_string(),
property_type: None,
}],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let response = LocalEvaluationResponse {
flags: vec![flag],
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
let mut properties = HashMap::new();
properties.insert("plan".to_string(), json!("premium"));
let result = evaluator.evaluate_flag(
"premium-feature",
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(FlagValue::Boolean(true)));
let mut properties = HashMap::new();
properties.insert("plan".to_string(), json!("free"));
let result = evaluator.evaluate_flag(
"premium-feature",
"user-456",
&properties,
&HashMap::new(),
&HashMap::new(),
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(FlagValue::Boolean(false)));
}
#[test]
fn test_local_evaluation_missing_flag() {
let cache = FlagCache::new();
let evaluator = LocalEvaluator::new(cache);
let properties = HashMap::new();
let result = evaluator.evaluate_flag(
"non-existent",
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), None);
}
#[cfg(feature = "async-client")]
#[tokio::test]
async fn test_local_evaluation_with_mock_server() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [
{
"key": "feature-a",
"active": true,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 50.0,
"variant": null
}
],
"multivariate": null,
"payloads": {}
}
},
{
"key": "feature-b",
"active": true,
"filters": {
"groups": [
{
"properties": [
{
"key": "email",
"value": "@company.com",
"operator": "icontains"
}
],
"rollout_percentage": 100.0,
"variant": null
}
],
"multivariate": null,
"payloads": {}
}
}
],
"group_type_mapping": {},
"cohorts": {}
});
let eval_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.header("Authorization", "Bearer test_personal_key")
.header("X-PostHog-Project-Api-Key", "test_project_key")
.query_param("send_cohorts", "");
then.status(200).json_body(mock_flags);
});
let options = ClientOptionsBuilder::default()
.host(server.base_url())
.api_key("test_project_key".to_string())
.personal_api_key("test_personal_key".to_string())
.enable_local_evaluation(true)
.poll_interval_seconds(60)
.build()
.unwrap();
let client = posthog_rs::client(options).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("test@company.com"));
let result = client
.get_feature_flag("feature-b", "user-123", None, Some(properties), None)
.await;
assert!(result.unwrap() == Some(FlagValue::Boolean(true)));
eval_mock.assert();
}
#[test]
fn test_cache_operations() {
let cache = FlagCache::new();
let flags = vec![
FeatureFlag {
key: "flag1".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
},
FeatureFlag {
key: "flag2".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
},
];
let response = LocalEvaluationResponse {
flags: flags.clone(),
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
assert!(cache.get_flag("flag1").is_some());
assert!(cache.get_flag("flag2").is_some());
assert!(cache.get_flag("flag3").is_none());
let all_flags = cache.get_all_flags();
assert_eq!(all_flags.len(), 2);
cache.clear();
assert!(cache.get_flag("flag1").is_none());
assert_eq!(cache.get_all_flags().len(), 0);
}
#[cfg(feature = "async-client")]
#[tokio::test]
async fn test_etag_sent_on_second_poll() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "test-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "")
.matches(|req| {
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"abc123\""
})
})
});
then.status(304);
});
let no_etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "");
then.status(200)
.header("ETag", "\"abc123\"")
.json_body(mock_flags.clone());
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(100),
request_timeout: Duration::from_secs(5),
};
let mut poller = AsyncFlagPoller::new(config, cache.clone());
poller.start().await;
tokio::time::sleep(Duration::from_millis(350)).await;
poller.stop().await;
assert!(
no_etag_mock.hits() >= 2,
"Should have at least 2 requests without If-None-Match (initial + first poll), got {}",
no_etag_mock.hits()
);
assert!(
etag_mock.hits() >= 1,
"Should have at least 1 request with If-None-Match header, got {}",
etag_mock.hits()
);
assert!(
cache.get_flag("test-flag").is_some(),
"Flag should be in cache"
);
}
#[cfg(feature = "async-client")]
#[tokio::test]
async fn test_304_preserves_cache() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "preserved-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "")
.matches(|req| {
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"v1\""
})
})
});
then.status(304);
});
server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "");
then.status(200)
.header("ETag", "\"v1\"")
.json_body(mock_flags);
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(100),
request_timeout: Duration::from_secs(5),
};
let mut poller = AsyncFlagPoller::new(config, cache.clone());
poller.start().await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should be loaded initially"
);
tokio::time::sleep(Duration::from_millis(350)).await;
poller.stop().await;
assert!(
etag_mock.hits() >= 1,
"Should have received at least one 304 response, got {} hits",
etag_mock.hits()
);
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should remain in cache after 304 response"
);
}
#[cfg(feature = "async-client")]
#[tokio::test]
async fn test_no_etag_from_server() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "no-etag-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let mock = server.mock(|when, then| {
when.method(GET).path("/flags/definitions/");
then.status(200).json_body(mock_flags);
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(50),
request_timeout: Duration::from_secs(5),
};
let mut poller = AsyncFlagPoller::new(config, cache.clone());
poller.start().await;
tokio::time::sleep(Duration::from_millis(150)).await;
poller.stop().await;
assert!(
mock.hits() >= 2,
"Should have made multiple requests without ETag"
);
assert!(
cache.get_flag("no-etag-flag").is_some(),
"Flag should be in cache"
);
}
#[test]
fn test_sync_etag_sent_on_second_poll() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "test-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "")
.matches(|req| {
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"sync-abc123\""
})
})
});
then.status(304);
});
let no_etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "");
then.status(200)
.header("ETag", "\"sync-abc123\"")
.json_body(mock_flags.clone());
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(100),
request_timeout: Duration::from_secs(5),
};
let mut poller = FlagPoller::new(config, cache.clone());
poller.start();
std::thread::sleep(Duration::from_millis(350));
poller.stop();
assert!(
no_etag_mock.hits() >= 2,
"Should have at least 2 requests without If-None-Match (initial + first poll), got {}",
no_etag_mock.hits()
);
assert!(
etag_mock.hits() >= 1,
"Should have at least 1 request with If-None-Match header, got {}",
etag_mock.hits()
);
assert!(
cache.get_flag("test-flag").is_some(),
"Flag should be in cache"
);
}
#[test]
fn test_sync_304_preserves_cache() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "preserved-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "")
.matches(|req| {
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"sync-v1\""
})
})
});
then.status(304);
});
server.mock(|when, then| {
when.method(GET)
.path("/flags/definitions/")
.query_param("send_cohorts", "");
then.status(200)
.header("ETag", "\"sync-v1\"")
.json_body(mock_flags);
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(100),
request_timeout: Duration::from_secs(5),
};
let mut poller = FlagPoller::new(config, cache.clone());
poller.start();
std::thread::sleep(Duration::from_millis(50));
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should be loaded initially"
);
std::thread::sleep(Duration::from_millis(350));
poller.stop();
assert!(
etag_mock.hits() >= 1,
"Should have received at least one 304 response, got {} hits",
etag_mock.hits()
);
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should remain in cache after 304 response"
);
}
#[test]
fn test_sync_no_etag_from_server() {
let server = MockServer::start();
let mock_flags = json!({
"flags": [{
"key": "no-etag-flag",
"active": true,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100.0, "variant": null}],
"multivariate": null,
"payloads": {}
}
}],
"group_type_mapping": {},
"cohorts": {}
});
let mock = server.mock(|when, then| {
when.method(GET).path("/flags/definitions/");
then.status(200).json_body(mock_flags);
});
let cache = FlagCache::new();
let config = LocalEvaluationConfig {
personal_api_key: "test_personal_key".to_string(),
project_api_key: "test_project_key".to_string(),
api_host: server.base_url(),
poll_interval: Duration::from_millis(50),
request_timeout: Duration::from_secs(5),
};
let mut poller = FlagPoller::new(config, cache.clone());
poller.start();
std::thread::sleep(Duration::from_millis(150));
poller.stop();
assert!(
mock.hits() >= 2,
"Should have made multiple requests without ETag"
);
assert!(
cache.get_flag("no-etag-flag").is_some(),
"Flag should be in cache"
);
}
fn mixed_flag() -> FeatureFlag {
FeatureFlag {
key: "mixed-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![
FeatureFlagCondition {
properties: vec![Property {
key: "plan".to_string(),
value: json!("enterprise"),
operator: "exact".to_string(),
property_type: Some("group".to_string()),
}],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: Some(0),
},
FeatureFlagCondition {
properties: vec![Property {
key: "email".to_string(),
value: json!("test@example.com"),
operator: "exact".to_string(),
property_type: Some("person".to_string()),
}],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
},
],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None, },
}
}
fn only_group_flag() -> FeatureFlag {
FeatureFlag {
key: "only-group-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![Property {
key: "plan".to_string(),
value: json!("enterprise"),
operator: "exact".to_string(),
property_type: Some("group".to_string()),
}],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: Some(0),
},
}
}
fn cache_with(flag: FeatureFlag) -> FlagCache {
let cache = FlagCache::new();
let mut group_type_mapping = HashMap::new();
group_type_mapping.insert("0".to_string(), "company".to_string());
cache.update(LocalEvaluationResponse {
flags: vec![flag],
group_type_mapping,
cohorts: HashMap::new(),
});
cache
}
#[test]
fn test_pure_group_flag_matches_with_group_passed_in() {
let evaluator = LocalEvaluator::new(cache_with(only_group_flag()));
let mut groups = HashMap::new();
groups.insert("company".to_string(), "acme".to_string());
let mut group_props = HashMap::new();
let mut acme_props = HashMap::new();
acme_props.insert("plan".to_string(), json!("enterprise"));
group_props.insert("company".to_string(), acme_props);
let result = evaluator
.evaluate_flag(
"only-group-flag",
"any-distinct-id",
&HashMap::new(),
&groups,
&group_props,
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(true)));
}
#[test]
fn test_pure_group_flag_returns_false_when_groups_not_passed() {
let evaluator = LocalEvaluator::new(cache_with(only_group_flag()));
let result = evaluator
.evaluate_flag(
"only-group-flag",
"any-distinct-id",
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(false)));
}
#[test]
fn test_mixed_flag_person_condition_matches_when_no_groups() {
let evaluator = LocalEvaluator::new(cache_with(mixed_flag()));
let mut person = HashMap::new();
person.insert("email".to_string(), json!("test@example.com"));
let result = evaluator
.evaluate_flag(
"mixed-flag",
"user-1",
&person,
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(true)));
}
#[test]
fn test_mixed_flag_group_condition_matches_with_group_props() {
let evaluator = LocalEvaluator::new(cache_with(mixed_flag()));
let mut groups = HashMap::new();
groups.insert("company".to_string(), "acme".to_string());
let mut group_props = HashMap::new();
let mut acme = HashMap::new();
acme.insert("plan".to_string(), json!("enterprise"));
group_props.insert("company".to_string(), acme);
let mut person = HashMap::new();
person.insert("email".to_string(), json!("nope@example.com"));
let result = evaluator
.evaluate_flag("mixed-flag", "user-2", &person, &groups, &group_props)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(true)));
}
#[test]
fn test_mixed_flag_no_match_when_both_fail() {
let evaluator = LocalEvaluator::new(cache_with(mixed_flag()));
let mut groups = HashMap::new();
groups.insert("company".to_string(), "acme".to_string());
let mut group_props = HashMap::new();
let mut acme = HashMap::new();
acme.insert("plan".to_string(), json!("free"));
group_props.insert("company".to_string(), acme);
let mut person = HashMap::new();
person.insert("email".to_string(), json!("nope@example.com"));
let result = evaluator
.evaluate_flag("mixed-flag", "user-3", &person, &groups, &group_props)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(false)));
}
#[test]
fn test_mixed_flag_only_group_condition_no_groups_returns_false() {
let flag = FeatureFlag {
key: "mixed-only-group".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![Property {
key: "plan".to_string(),
value: json!("enterprise"),
operator: "exact".to_string(),
property_type: Some("group".to_string()),
}],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: Some(0),
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let evaluator = LocalEvaluator::new(cache_with(flag));
let result = evaluator
.evaluate_flag(
"mixed-only-group",
"user-1",
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(false)));
}
#[test]
fn test_group_condition_uses_group_key_for_bucketing() {
let flag = FeatureFlag {
key: "rollout-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(50.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: Some(0),
},
};
let evaluator = LocalEvaluator::new(cache_with(flag));
let distinct_id = "user-0";
let mut groups_in = HashMap::new();
groups_in.insert("company".to_string(), "company-7".to_string());
let mut group_props = HashMap::new();
group_props.insert("company".to_string(), HashMap::new());
let result = evaluator
.evaluate_flag(
"rollout-flag",
distinct_id,
&HashMap::new(),
&groups_in,
&group_props,
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(true)));
let mut groups_out = HashMap::new();
groups_out.insert("company".to_string(), "company-2".to_string());
let result = evaluator
.evaluate_flag(
"rollout-flag",
distinct_id,
&HashMap::new(),
&groups_out,
&group_props,
)
.unwrap();
assert_eq!(result, Some(FlagValue::Boolean(false)));
}