Files
insights-rs/tests/test_local_evaluation.rs
zeekay 0b551fde0a Rename crate from posthog-rs to insights-rs
Full rebrand: package name, all source references, docs, examples.
2026-03-06 22:42:03 -08:00

701 lines
22 KiB
Rust

use httpmock::prelude::*;
#[cfg(feature = "async-client")]
use insights_rs::AsyncFlagPoller;
use insights_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() {
// Create a cache and evaluator
let cache = FlagCache::new();
let evaluator = LocalEvaluator::new(cache.clone());
// Create a simple flag
let flag = FeatureFlag {
key: "test-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
}],
multivariate: None,
payloads: HashMap::new(),
},
};
// Update cache with the flag
let response = LocalEvaluationResponse {
flags: vec![flag],
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
// Test evaluation
let properties = HashMap::new();
let result = evaluator.evaluate_flag("test-flag", "user-123", &properties);
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());
// Create a flag with property conditions
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,
}],
multivariate: None,
payloads: HashMap::new(),
},
};
// Update cache
let response = LocalEvaluationResponse {
flags: vec![flag],
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
// Test with matching properties
let mut properties = HashMap::new();
properties.insert("plan".to_string(), json!("premium"));
let result = evaluator.evaluate_flag("premium-feature", "user-123", &properties);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(FlagValue::Boolean(true)));
// Test with non-matching properties
let mut properties = HashMap::new();
properties.insert("plan".to_string(), json!("free"));
let result = evaluator.evaluate_flag("premium-feature", "user-456", &properties);
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);
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();
// Mock the local evaluation endpoint
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("/api/feature_flag/local_evaluation/")
.header("Authorization", "Bearer test_personal_key")
.header("X-Insights-Project-Api-Key", "test_project_key")
.query_param("send_cohorts", "");
then.status(200).json_body(mock_flags);
});
// Create client with local evaluation enabled
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 = insights_rs::client(options).await;
// Give it a moment to load initial flags
tokio::time::sleep(Duration::from_millis(100)).await;
// Test local evaluation
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("[email protected]"));
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();
// Create multiple flags
let flags = vec![
FeatureFlag {
key: "flag1".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
},
},
FeatureFlag {
key: "flag2".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
},
},
];
let response = LocalEvaluationResponse {
flags: flags.clone(),
group_type_mapping: HashMap::new(),
cohorts: HashMap::new(),
};
cache.update(response);
// Test get_flag
assert!(cache.get_flag("flag1").is_some());
assert!(cache.get_flag("flag2").is_some());
assert!(cache.get_flag("flag3").is_none());
// Test get_all_flags
let all_flags = cache.get_all_flags();
assert_eq!(all_flags.len(), 2);
// Test clear
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": {}
});
// Mock for requests WITH If-None-Match header (subsequent polls) -> 304
// Registered FIRST but uses matches() to only match when header is present with correct value
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.query_param("send_cohorts", "")
.matches(|req| {
// Match only if If-None-Match header exists with the correct value
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"abc123\""
})
})
});
then.status(304);
});
// Mock for requests WITHOUT If-None-Match (initial load + first poll) -> 200 with ETag
// Registered SECOND - will be tried first but etag_mock's matches() will fail if no header
let no_etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.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;
// Wait for:
// - Initial load (immediate) -> gets 200 with ETag (load_flags doesn't set last_etag)
// - First poll tick (after 100ms) -> gets 200, sets last_etag in the polling loop
// - Second poll tick (after 200ms) -> sends If-None-Match, gets 304
tokio::time::sleep(Duration::from_millis(350)).await;
poller.stop().await;
// Verify requests without If-None-Match were made (initial load + first poll)
assert!(
no_etag_mock.hits() >= 2,
"Should have at least 2 requests without If-None-Match (initial + first poll), got {}",
no_etag_mock.hits()
);
// Verify at least one request WITH If-None-Match was made (second+ poll)
assert!(
etag_mock.hits() >= 1,
"Should have at least 1 request with If-None-Match header, got {}",
etag_mock.hits()
);
// Verify cache has the flag (preserved after 304)
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": {}
});
// Mock for requests WITH If-None-Match -> returns 304
// Registered FIRST but uses matches() to only match when header is present with correct value
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.query_param("send_cohorts", "")
.matches(|req| {
// Match only if If-None-Match header exists with the correct value
req.headers.as_ref().is_some_and(|headers| {
headers.iter().any(|(name, value)| {
name.to_lowercase() == "if-none-match" && value == "\"v1\""
})
})
});
then.status(304);
});
// Mock for requests WITHOUT If-None-Match -> returns 200 with ETag
// Registered SECOND - will be tried first but etag_mock's matches() will fail if no header
server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.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;
// Wait for initial load
tokio::time::sleep(Duration::from_millis(50)).await;
// Verify flag is in cache after initial load
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should be loaded initially"
);
// Wait for multiple poll cycles (enough for at least one 304 response)
tokio::time::sleep(Duration::from_millis(350)).await;
poller.stop().await;
// Verify 304 mock was hit (proves If-None-Match was sent and 304 was received)
assert!(
etag_mock.hits() >= 1,
"Should have received at least one 304 response, got {} hits",
etag_mock.hits()
);
// Flag should still be present after 304 response (cache preserved)
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": {}
});
// Server returns 200 without ETag header
let mock = server.mock(|when, then| {
when.method(GET).path("/api/feature_flag/local_evaluation/");
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;
// Wait for initial load + a couple poll cycles
tokio::time::sleep(Duration::from_millis(150)).await;
poller.stop().await;
// Should have made multiple requests (initial + polls), all without If-None-Match
assert!(
mock.hits() >= 2,
"Should have made multiple requests without ETag"
);
// Cache should have the flag
assert!(
cache.get_flag("no-etag-flag").is_some(),
"Flag should be in cache"
);
}
// Sync FlagPoller tests
#[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": {}
});
// Mock for requests WITH If-None-Match header (subsequent polls) -> 304
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.query_param("send_cohorts", "")
.matches(|req| {
// Match only if If-None-Match header exists with the correct value
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);
});
// Mock for requests WITHOUT If-None-Match -> 200 with ETag
let no_etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.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();
// Wait for:
// - Initial load (immediate) -> gets 200 with ETag (load_flags doesn't set last_etag)
// - First poll tick (after 100ms) -> gets 200, sets last_etag
// - Second poll tick (after 200ms) -> sends If-None-Match, gets 304
std::thread::sleep(Duration::from_millis(350));
poller.stop();
// Verify requests without If-None-Match were made (initial load + first poll)
assert!(
no_etag_mock.hits() >= 2,
"Should have at least 2 requests without If-None-Match (initial + first poll), got {}",
no_etag_mock.hits()
);
// Verify at least one request WITH If-None-Match was made (second+ poll)
assert!(
etag_mock.hits() >= 1,
"Should have at least 1 request with If-None-Match header, got {}",
etag_mock.hits()
);
// Verify cache has the flag (preserved after 304)
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": {}
});
// Mock for requests WITH If-None-Match -> returns 304
let etag_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.query_param("send_cohorts", "")
.matches(|req| {
// Match only if If-None-Match header exists with the correct value
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);
});
// Mock for requests WITHOUT If-None-Match -> returns 200 with ETag
server.mock(|when, then| {
when.method(GET)
.path("/api/feature_flag/local_evaluation/")
.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();
// Wait for initial load
std::thread::sleep(Duration::from_millis(50));
// Verify flag is in cache after initial load
assert!(
cache.get_flag("preserved-flag").is_some(),
"Flag should be loaded initially"
);
// Wait for multiple poll cycles (enough for at least one 304 response)
std::thread::sleep(Duration::from_millis(350));
poller.stop();
// Verify 304 mock was hit (proves If-None-Match was sent and 304 was received)
assert!(
etag_mock.hits() >= 1,
"Should have received at least one 304 response, got {} hits",
etag_mock.hits()
);
// Flag should still be present after 304 response (cache preserved)
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": {}
});
// Server returns 200 without ETag header
let mock = server.mock(|when, then| {
when.method(GET).path("/api/feature_flag/local_evaluation/");
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();
// Wait for initial load + a couple poll cycles
std::thread::sleep(Duration::from_millis(150));
poller.stop();
// Should have made multiple requests (initial + polls), all without If-None-Match
assert!(
mock.hits() >= 2,
"Should have made multiple requests without ETag"
);
// Cache should have the flag
assert!(
cache.get_flag("no-etag-flag").is_some(),
"Flag should be in cache"
);
}