mirror of
https://github.com/hanzoai/insights-python.git
synced 2026-10-30 18:33:17 +00:00
- Rename main class Posthog -> Insights (Posthog kept as alias) - Rename PosthogContextMiddleware -> InsightsContextMiddleware (alias kept) - Rename PostHogTracingProcessor -> InsightsTracingProcessor (alias kept) - Add `insights/` re-export package so `from insights import Insights` works - Update all imports from `posthog` to `hanzo_insights` across source and tests - Update docstrings, comments, error messages, user agent string - Update README, example.py, .env.example, Makefile, LLM.md - Django middleware now supports INSIGHTS_MW_* settings (POSTHOG_MW_* still works) - Django middleware accepts X-INSIGHTS-* headers (X-POSTHOG-* still works) - Keep protocol-level values ($lib, ingestion URLs, sentinel strings) for server compat - Keep posthog_* parameter names in AI wrappers for API compat - All 681 tests pass
7387 lines
246 KiB
Python
7387 lines
246 KiB
Python
import datetime
|
||
import unittest
|
||
|
||
import mock
|
||
from dateutil import parser, tz
|
||
from freezegun import freeze_time
|
||
|
||
from hanzo_insights.client import Client
|
||
from hanzo_insights.feature_flags import (
|
||
InconclusiveMatchError,
|
||
match_property,
|
||
relative_date_parse_for_feature_flag_matching,
|
||
)
|
||
from hanzo_insights.request import APIError, GetResponse
|
||
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
|
||
|
||
|
||
class TestLocalEvaluation(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
# This ensures no real HTTP POST requests are made
|
||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||
cls.capture_patch.start()
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
cls.capture_patch.stop()
|
||
|
||
def set_fail(self, e, batch):
|
||
"""Mark the failure handler"""
|
||
print("FAIL", e, batch) # noqa: T201
|
||
self.failed = True
|
||
|
||
def setUp(self):
|
||
self.failed = False
|
||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_person_properties(self, patch_get):
|
||
self.client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "person-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
feature_flag_match = self.client.get_feature_flag(
|
||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||
)
|
||
|
||
not_feature_flag_match = self.client.get_feature_flag(
|
||
"person-flag", "some-distinct-2", person_properties={"region": "Canada"}
|
||
)
|
||
|
||
self.assertTrue(feature_flag_match)
|
||
self.assertFalse(not_feature_flag_match)
|
||
|
||
def test_case_insensitive_matching(self):
|
||
self.client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "person-flag",
|
||
"is_simple_flag": True,
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "location",
|
||
"operator": "exact",
|
||
"value": ["Straße"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "star",
|
||
"operator": "exact",
|
||
"value": ["ſun"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
},
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
self.assertTrue(
|
||
self.client.get_feature_flag(
|
||
"person-flag",
|
||
"some-distinct-id",
|
||
person_properties={"location": "straße"},
|
||
)
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.client.get_feature_flag(
|
||
"person-flag",
|
||
"some-distinct-id",
|
||
person_properties={"location": "strasse"},
|
||
)
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.client.get_feature_flag(
|
||
"person-flag", "some-distinct-id", person_properties={"star": "ſun"}
|
||
)
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.client.get_feature_flag(
|
||
"person-flag", "some-distinct-id", person_properties={"star": "sun"}
|
||
)
|
||
)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_group_properties(self, patch_get, patch_flags):
|
||
self.client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "group-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"group_type_index": 0,
|
||
"key": "name",
|
||
"operator": "exact",
|
||
"value": ["Project Name 1"],
|
||
"type": "group",
|
||
}
|
||
],
|
||
"rollout_percentage": 35,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
self.client.group_type_mapping = {"0": "company", "1": "project"}
|
||
|
||
# Group names not passed in
|
||
self.assertFalse(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-id",
|
||
group_properties={"company": {"name": "Project Name 1"}},
|
||
)
|
||
)
|
||
|
||
self.assertFalse(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-2",
|
||
group_properties={"company": {"name": "Project Name 2"}},
|
||
)
|
||
)
|
||
|
||
# this is good
|
||
self.assertTrue(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-id",
|
||
groups={"company": "amazon_without_rollout"},
|
||
group_properties={"company": {"name": "Project Name 1"}},
|
||
)
|
||
)
|
||
# rollout %
|
||
self.assertFalse(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-id",
|
||
groups={"company": "amazon"},
|
||
group_properties={"company": {"name": "Project Name 1"}},
|
||
)
|
||
)
|
||
|
||
# property mismatch
|
||
self.assertFalse(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-2",
|
||
groups={"company": "amazon_without_rollout"},
|
||
group_properties={"company": {"name": "Project Name 2"}},
|
||
)
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
# Now group type mappings are gone, so fall back to /flags/
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"group-flag": "decide-fallback-value"}
|
||
}
|
||
|
||
self.client.group_type_mapping = {}
|
||
self.assertEqual(
|
||
self.client.get_feature_flag(
|
||
"group-flag",
|
||
"some-distinct-id",
|
||
groups={"company": "amazon"},
|
||
group_properties={"company": {"name": "Project Name 1"}},
|
||
),
|
||
"decide-fallback-value",
|
||
)
|
||
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
def test_group_flag_is_inconclusive_when_group_properties_missing(self):
|
||
feature_flag = {
|
||
"id": 1,
|
||
"name": "Group Flag Without Property Filters",
|
||
"key": "group-flag-no-props",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
}
|
||
self.client.group_type_mapping = {"0": "company"}
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
self.client._compute_flag_locally(
|
||
feature_flag,
|
||
"some-distinct-id",
|
||
groups={"company": "acme"},
|
||
group_properties={},
|
||
)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_with_complex_definition(self, patch_get, patch_flags):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"complex-flag": "decide-fallback-value"}
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "complex-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
},
|
||
{
|
||
"key": "name",
|
||
"operator": "exact",
|
||
"value": ["Aloha"],
|
||
"type": "person",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"operator": "exact",
|
||
"value": ["[email protected]", "[email protected]"],
|
||
"type": "person",
|
||
},
|
||
],
|
||
"rollout_percentage": 30,
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "doesnt_matter",
|
||
"operator": "exact",
|
||
"value": ["1", "2"],
|
||
"type": "person",
|
||
},
|
||
],
|
||
"rollout_percentage": 0,
|
||
},
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
# this distinctIDs hash is < rollout %
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id_within_rollout?",
|
||
person_properties={"region": "USA", "email": "[email protected]"},
|
||
)
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
# will fall back on `/flags`, as all properties present for second group, but that group resolves to false
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id_outside_rollout?",
|
||
person_properties={"region": "USA", "email": "[email protected]"},
|
||
),
|
||
"decide-fallback-value",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
patch_flags.reset_mock()
|
||
|
||
# same as above
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id",
|
||
person_properties={"doesnt_matter": "1"},
|
||
),
|
||
"decide-fallback-value",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
patch_flags.reset_mock()
|
||
|
||
# this one will need to fall back
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"complex-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||
),
|
||
"decide-fallback-value",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
patch_flags.reset_mock()
|
||
|
||
# won't need to fall back when all values are present
|
||
self.assertFalse(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id_outside_rollout?",
|
||
person_properties={
|
||
"region": "USA",
|
||
"email": "[email protected]",
|
||
"name": "X",
|
||
"doesnt_matter": "1",
|
||
},
|
||
)
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_fallback_to_flags(self, patch_get, patch_flags):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "id",
|
||
"value": 98,
|
||
"operator": None,
|
||
"type": "cohort",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# beta-feature fallbacks to decide because property type is unknown
|
||
feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id")
|
||
|
||
self.assertEqual(feature_flag_match, "alakazam")
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
# beta-feature2 fallbacks to decide because region property not given with call
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature2", "some-distinct-id"
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, "alakazam2")
|
||
self.assertEqual(patch_flags.call_count, 2)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_dont_fallback_to_flags_when_only_local_evaluation_is_true(
|
||
self, patch_get, patch_flags
|
||
):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "id",
|
||
"value": 98,
|
||
"operator": None,
|
||
"type": "cohort",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# beta-feature should fallback to decide because property type is unknown,
|
||
# but doesn't because only_evaluate_locally is true
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature", "some-distinct-id", only_evaluate_locally=True
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, None)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
feature_flag_match = client.feature_enabled(
|
||
"beta-feature", "some-distinct-id", only_evaluate_locally=True
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, None)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
# beta-feature2 should fallback to decide because region property not given with call
|
||
# but doesn't because only_evaluate_locally is true
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature2", "some-distinct-id", only_evaluate_locally=True
|
||
)
|
||
self.assertEqual(feature_flag_match, None)
|
||
|
||
feature_flag_match = client.feature_enabled(
|
||
"beta-feature2", "some-distinct-id", only_evaluate_locally=True
|
||
)
|
||
self.assertEqual(feature_flag_match, None)
|
||
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flag_never_returns_undefined_during_regular_evaluation(
|
||
self, patch_get, patch_flags
|
||
):
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# beta-feature resolves to False, so no matter the default, stays False
|
||
self.assertFalse(client.get_feature_flag("beta-feature", "some-distinct-id"))
|
||
self.assertFalse(client.feature_enabled("beta-feature", "some-distinct-id"))
|
||
|
||
# beta-feature2 falls back to decide, and whatever decide returns is the value
|
||
self.assertFalse(client.get_feature_flag("beta-feature2", "some-distinct-id"))
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
self.assertFalse(client.feature_enabled("beta-feature2", "some-distinct-id"))
|
||
self.assertEqual(patch_flags.call_count, 2)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flag_return_none_when_decide_errors_out(
|
||
self, patch_get, patch_flags
|
||
):
|
||
patch_flags.side_effect = APIError(400, "Decide error")
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = []
|
||
|
||
# beta-feature2 falls back to decide, which on error returns None
|
||
self.assertIsNone(client.get_feature_flag("beta-feature2", "some-distinct-id"))
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
self.assertIsNone(client.feature_enabled("beta-feature2", "some-distinct-id"))
|
||
self.assertEqual(patch_flags.call_count, 2)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_experience_continuity_flag_not_evaluated_locally(self, patch_flags):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "decide-fallback-value"}
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
"ensure_experience_continuity": True,
|
||
}
|
||
]
|
||
# decide called always because experience_continuity is set
|
||
self.assertEqual(
|
||
client.get_feature_flag("beta-feature", "distinct_id"),
|
||
"decide-fallback-value",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_fallback(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {
|
||
"beta-feature": "variant-1",
|
||
"beta-feature2": "variant-2",
|
||
"disabled-feature": False,
|
||
}
|
||
} # decide should return the same flags
|
||
client = self.client
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 3,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "country", "value": "US"}],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
# beta-feature value overridden by /flags
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id"),
|
||
{
|
||
"beta-feature": "variant-1",
|
||
"beta-feature2": "variant-2",
|
||
"disabled-feature": False,
|
||
},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||
}
|
||
client = self.client
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "some-payload",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "another-payload",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"id": 3,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "country", "value": "US"}],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "payload-3",
|
||
},
|
||
},
|
||
},
|
||
]
|
||
# beta-feature value overridden by /flags
|
||
self.assertEqual(
|
||
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
||
{
|
||
"beta-feature": 100,
|
||
"beta-feature2": 300,
|
||
},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_fallback_empty_local_flags(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
|
||
}
|
||
client = self.client
|
||
client.feature_flags = []
|
||
# beta-feature value overridden by /flags
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id"),
|
||
{"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||
}
|
||
client = self.client
|
||
client.feature_flags = []
|
||
# beta-feature value overridden by /flags
|
||
self.assertEqual(
|
||
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
||
{"beta-feature": 100, "beta-feature2": 300},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_no_fallback(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
|
||
}
|
||
client = self.client
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id"),
|
||
{"beta-feature": True, "disabled-feature": False},
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_and_payloads_with_no_fallback(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
client = self.client
|
||
basic_flag = {
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "new",
|
||
},
|
||
},
|
||
}
|
||
disabled_flag = {
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "some-payload",
|
||
},
|
||
},
|
||
}
|
||
client.feature_flags = [
|
||
basic_flag,
|
||
disabled_flag,
|
||
]
|
||
self.assertEqual(
|
||
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
||
{"beta-feature": "new"},
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
|
||
}
|
||
client = self.client
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 3,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "country", "value": "US"}],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
# beta-feature2 has no value
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id", only_evaluate_locally=True),
|
||
{"beta-feature": True, "disabled-feature": False},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||
}
|
||
client = self.client
|
||
flag_1 = {
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "some-payload",
|
||
},
|
||
},
|
||
}
|
||
flag_2 = {
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "another-payload",
|
||
},
|
||
},
|
||
}
|
||
flag_3 = {
|
||
"id": 3,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature2",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "country", "value": "US"}],
|
||
"rollout_percentage": 0,
|
||
}
|
||
],
|
||
"payloads": {
|
||
"true": "payload-3",
|
||
},
|
||
},
|
||
}
|
||
client.feature_flags = [
|
||
flag_1,
|
||
flag_2,
|
||
flag_3,
|
||
]
|
||
# beta-feature2 has no value
|
||
self.assertEqual(
|
||
client.get_all_flags_and_payloads(
|
||
"distinct_id", only_evaluate_locally=True
|
||
)["featureFlagPayloads"],
|
||
{"beta-feature": "some-payload"},
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_compute_inactive_flags_locally(self, patch_flags, patch_capture):
|
||
client = self.client
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id"),
|
||
{"beta-feature": True, "disabled-feature": False},
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
# Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%.
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": False,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "disabled-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
self.assertEqual(
|
||
client.get_all_flags("distinct_id"),
|
||
{"beta-feature": False, "disabled-feature": True},
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_flags):
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
id: 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"variant": None,
|
||
"properties": [
|
||
{
|
||
"key": "latestBuildVersion",
|
||
"type": "person",
|
||
"value": ".+",
|
||
"operator": "regex",
|
||
},
|
||
{
|
||
"key": "latestBuildVersionMajor",
|
||
"type": "person",
|
||
"value": "23",
|
||
"operator": "gt",
|
||
},
|
||
{
|
||
"key": "latestBuildVersionMinor",
|
||
"type": "person",
|
||
"value": "31",
|
||
"operator": "gt",
|
||
},
|
||
{
|
||
"key": "latestBuildVersionPatch",
|
||
"type": "person",
|
||
"value": "0",
|
||
"operator": "gt",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={
|
||
"latestBuildVersion": None,
|
||
"latestBuildVersionMajor": None,
|
||
"latestBuildVersionMinor": None,
|
||
"latestBuildVersionPatch": None,
|
||
},
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, False)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={
|
||
"latestBuildVersion": "24.32.1",
|
||
"latestBuildVersionMajor": "24",
|
||
"latestBuildVersionMinor": "32",
|
||
"latestBuildVersionPatch": "1",
|
||
},
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, True)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_flags):
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
},
|
||
{
|
||
"key": "id",
|
||
"value": 98,
|
||
"operator": None,
|
||
"type": "cohort",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
client.cohorts = {
|
||
"98": {
|
||
"type": "OR",
|
||
"values": [
|
||
{"key": "id", "value": 1, "type": "cohort"},
|
||
{
|
||
"key": "nation",
|
||
"operator": "exact",
|
||
"value": ["UK"],
|
||
"type": "person",
|
||
},
|
||
],
|
||
},
|
||
"1": {
|
||
"type": "AND",
|
||
"values": [
|
||
{
|
||
"key": "other",
|
||
"operator": "exact",
|
||
"value": ["thing"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
},
|
||
}
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature", "some-distinct-id", person_properties={"region": "UK"}
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, False)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "nation": "UK"},
|
||
)
|
||
# even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||
self.assertEqual(feature_flag_match, True)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "other": "thing"},
|
||
)
|
||
self.assertEqual(feature_flag_match, True)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_local_evaluation_for_negated_cohorts(
|
||
self, patch_get, patch_flags
|
||
):
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 2,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
},
|
||
{
|
||
"key": "id",
|
||
"value": 98,
|
||
"operator": None,
|
||
"type": "cohort",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
client.cohorts = {
|
||
"98": {
|
||
"type": "OR",
|
||
"values": [
|
||
{"key": "id", "value": 1, "type": "cohort"},
|
||
{
|
||
"key": "nation",
|
||
"operator": "exact",
|
||
"value": ["UK"],
|
||
"type": "person",
|
||
},
|
||
],
|
||
},
|
||
"1": {
|
||
"type": "AND",
|
||
"values": [
|
||
{
|
||
"key": "other",
|
||
"operator": "exact",
|
||
"value": ["thing"],
|
||
"type": "person",
|
||
"negation": True,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature", "some-distinct-id", person_properties={"region": "UK"}
|
||
)
|
||
|
||
self.assertEqual(feature_flag_match, False)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "nation": "UK"},
|
||
)
|
||
# even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||
self.assertEqual(feature_flag_match, True)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "other": "thing"},
|
||
)
|
||
# since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
patch_flags.reset_mock()
|
||
|
||
feature_flag_match = client.get_feature_flag(
|
||
"beta-feature",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "other": "thing2"},
|
||
)
|
||
self.assertEqual(feature_flag_match, True)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.feature_flags.log")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_flags_with_flag_dependencies(
|
||
self, patch_get, patch_flags, mock_log
|
||
):
|
||
# Mock remote flags call to return empty for this flag (fallback returns None)
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Flag with Dependencies",
|
||
"key": "flag-with-dependencies",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "beta-feature",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["beta-feature"],
|
||
},
|
||
{
|
||
"key": "email",
|
||
"operator": "icontains",
|
||
"value": "@example.com",
|
||
"type": "person",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Test that flag evaluation handles flag dependencies properly
|
||
# The flag has a dependency on "beta-feature" which doesn't exist locally
|
||
# Since the dependency doesn't exist, local evaluation should fail and fall back to remote
|
||
# Remote returns empty result, so final result is None
|
||
feature_flag_match = client.get_feature_flag(
|
||
"flag-with-dependencies",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertIsNone(feature_flag_match)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
# Test with email that doesn't match (should also fall back to remote due to missing dependency)
|
||
feature_flag_match = client.get_feature_flag(
|
||
"flag-with-dependencies",
|
||
"test-user-2",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertIsNone(feature_flag_match)
|
||
self.assertEqual(patch_flags.call_count, 2) # Called twice now
|
||
self.assertEqual(patch_get.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_simple_chain(self, patch_get, patch_flags):
|
||
"""Test basic flag dependency: flag-b depends on flag-a"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Flag A",
|
||
"key": "flag-a",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"operator": "icontains",
|
||
"value": "@example.com",
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Flag B",
|
||
"key": "flag-b",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "flag-a",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["flag-a"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Test when dependency is satisfied
|
||
result = client.get_feature_flag(
|
||
"flag-b",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, True)
|
||
|
||
# Test when dependency is not satisfied
|
||
result = client.get_feature_flag(
|
||
"flag-b",
|
||
"test-user-2",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, False)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_circular_dependency(self, patch_get, patch_flags):
|
||
"""Test circular dependency handling: flag-a depends on flag-b, flag-b depends on flag-a"""
|
||
# Mock remote flags call to return empty for these flags (fallback returns None)
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Flag A",
|
||
"key": "flag-a",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "flag-b",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": [], # Empty chain indicates circular dependency
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Flag B",
|
||
"key": "flag-b",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "flag-a",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": [], # Empty chain indicates circular dependency
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Both flags should fall back to remote evaluation due to circular dependency
|
||
# Since we're not mocking the remote call, both should return None
|
||
result_a = client.get_feature_flag("flag-a", "test-user")
|
||
self.assertIsNone(result_a)
|
||
|
||
result_b = client.get_feature_flag("flag-b", "test-user")
|
||
self.assertIsNone(result_b)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_missing_flag(self, patch_get, patch_flags):
|
||
"""Test handling of missing flag dependency"""
|
||
# Mock remote flags call to return empty for this flag (fallback returns None)
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Flag A",
|
||
"key": "flag-a",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "non-existent-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["non-existent-flag"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Should fall back to remote evaluation because dependency doesn't exist
|
||
# Since we're not mocking the remote call, should return None
|
||
result = client.get_feature_flag("flag-a", "test-user")
|
||
self.assertIsNone(result)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_complex_chain(self, patch_get, patch_flags):
|
||
"""Test complex dependency chain: flag-d -> flag-c -> [flag-a, flag-b]"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Flag A",
|
||
"key": "flag-a",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Flag B",
|
||
"key": "flag-b",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 3,
|
||
"name": "Flag C",
|
||
"key": "flag-c",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "flag-a",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["flag-a"],
|
||
},
|
||
{
|
||
"key": "flag-b",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["flag-b"],
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 4,
|
||
"name": "Flag D",
|
||
"key": "flag-d",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "flag-c",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["flag-a", "flag-b", "flag-c"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# All dependencies satisfied - should return True
|
||
result = client.get_feature_flag("flag-d", "test-user")
|
||
self.assertEqual(result, True)
|
||
|
||
# Make flag-a inactive - should break the chain
|
||
client.feature_flags[0]["active"] = False
|
||
result = client.get_feature_flag("flag-d", "test-user")
|
||
self.assertEqual(result, False)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_mixed_conditions(self, patch_get, patch_flags):
|
||
"""Test flag dependency mixed with other property conditions"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Base Flag",
|
||
"key": "base-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Mixed Flag",
|
||
"key": "mixed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "base-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["base-flag"],
|
||
},
|
||
{
|
||
"key": "email",
|
||
"operator": "icontains",
|
||
"value": "@example.com",
|
||
"type": "person",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Both flag dependency and email condition satisfied
|
||
result = client.get_feature_flag(
|
||
"mixed-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, True)
|
||
|
||
# Flag dependency satisfied but email condition not satisfied
|
||
result = client.get_feature_flag(
|
||
"mixed-flag",
|
||
"test-user-2",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, False)
|
||
|
||
# Email condition satisfied but flag dependency not satisfied
|
||
client.feature_flags[0]["active"] = False
|
||
result = client.get_feature_flag(
|
||
"mixed-flag",
|
||
"test-user-3",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, False)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_flag_dependencies_malformed_chain(self, patch_get, patch_flags):
|
||
"""Test handling of malformed dependency chains"""
|
||
# Mock remote flags call to return empty for this flag (fallback returns None)
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Base Flag",
|
||
"key": "base-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Missing Chain Flag",
|
||
"key": "missing-chain-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "base-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
# No dependency_chain property - should evaluate as inconclusive
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Should fall back to remote evaluation when dependency_chain is missing
|
||
# Since we're not mocking the remote call, should return None
|
||
result = client.get_feature_flag("missing-chain-flag", "test-user")
|
||
self.assertIsNone(result)
|
||
|
||
def test_flag_dependencies_without_context_raises_inconclusive(self):
|
||
"""Test that missing flags_by_key raises InconclusiveMatchError"""
|
||
from hanzo_insights.feature_flags import (
|
||
evaluate_flag_dependency,
|
||
InconclusiveMatchError,
|
||
)
|
||
|
||
property_with_flag_dep = {
|
||
"key": "some-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["some-flag"],
|
||
}
|
||
|
||
# Should raise InconclusiveMatchError when flags_by_key is None
|
||
with self.assertRaises(InconclusiveMatchError) as cm:
|
||
evaluate_flag_dependency(
|
||
property_with_flag_dep,
|
||
flags_by_key=None, # This should trigger the error
|
||
evaluation_cache={},
|
||
distinct_id="test-user",
|
||
properties={},
|
||
cohort_properties={},
|
||
)
|
||
|
||
self.assertIn("Cannot evaluate flag dependency", str(cm.exception))
|
||
self.assertIn("some-flag", str(cm.exception))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_multi_level_multivariate_dependency_chain(self, patch_get, patch_flags):
|
||
"""Test multi-level multivariate dependency chain: dependent-flag -> intermediate-flag -> leaf-flag"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
# Leaf flag: multivariate with "control" and "test" variants using person property overrides
|
||
{
|
||
"id": 1,
|
||
"name": "Leaf Flag",
|
||
"key": "leaf-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "control",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "test",
|
||
},
|
||
{
|
||
"rollout_percentage": 50,
|
||
"variant": "control",
|
||
}, # Default fallback
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "control",
|
||
"name": "Control",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{"key": "test", "name": "Test", "rollout_percentage": 50},
|
||
]
|
||
},
|
||
},
|
||
},
|
||
# Intermediate flag: multivariate with "blue" and "green" variants, depends on leaf-flag="control"
|
||
{
|
||
"id": 2,
|
||
"name": "Intermediate Flag",
|
||
"key": "intermediate-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "leaf-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": "control",
|
||
"type": "flag",
|
||
"dependency_chain": ["leaf-flag"],
|
||
},
|
||
{
|
||
"key": "variant_type",
|
||
"type": "person",
|
||
"value": "blue",
|
||
"operator": "exact",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "blue",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "leaf-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": "control",
|
||
"type": "flag",
|
||
"dependency_chain": ["leaf-flag"],
|
||
},
|
||
{
|
||
"key": "variant_type",
|
||
"type": "person",
|
||
"value": "green",
|
||
"operator": "exact",
|
||
},
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "green",
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "blue", "name": "Blue", "rollout_percentage": 50},
|
||
{"key": "green", "name": "Green", "rollout_percentage": 50},
|
||
]
|
||
},
|
||
},
|
||
},
|
||
# Dependent flag: boolean flag that depends on intermediate-flag="blue"
|
||
{
|
||
"id": 3,
|
||
"name": "Dependent Flag",
|
||
"key": "dependent-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "intermediate-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": "blue",
|
||
"type": "flag",
|
||
"dependency_chain": [
|
||
"leaf-flag",
|
||
"intermediate-flag",
|
||
],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Test using person properties and variant overrides to ensure predictable variants
|
||
|
||
# Test 1: Make sure the leaf flag evaluates to the variant we expect using email overrides
|
||
self.assertEqual(
|
||
"control",
|
||
client.get_feature_flag(
|
||
"leaf-flag",
|
||
"any-user",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
)
|
||
self.assertEqual(
|
||
"test",
|
||
client.get_feature_flag(
|
||
"leaf-flag",
|
||
"any-user",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
)
|
||
|
||
# Test 2: Make sure the intermediate flag evaluates to the expected variants when dependency is satisfied
|
||
self.assertEqual(
|
||
"blue",
|
||
client.get_feature_flag(
|
||
"intermediate-flag",
|
||
"any-user",
|
||
person_properties={
|
||
"email": "[email protected]",
|
||
"variant_type": "blue",
|
||
},
|
||
),
|
||
)
|
||
|
||
self.assertEqual(
|
||
"green",
|
||
client.get_feature_flag(
|
||
"intermediate-flag",
|
||
"any-user",
|
||
person_properties={
|
||
"email": "[email protected]",
|
||
"variant_type": "green",
|
||
},
|
||
),
|
||
)
|
||
|
||
# Test 3: Make sure the intermediate flag evaluates to false when leaf dependency fails
|
||
self.assertEqual(
|
||
False,
|
||
client.get_feature_flag(
|
||
"intermediate-flag",
|
||
"any-user",
|
||
person_properties={
|
||
"email": "[email protected]", # This makes leaf-flag="test", breaking dependency
|
||
"variant_type": "blue",
|
||
},
|
||
),
|
||
)
|
||
|
||
# Test 4: When leaf-flag="control", intermediate="blue", dependent should be true
|
||
self.assertEqual(
|
||
True,
|
||
client.get_feature_flag(
|
||
"dependent-flag",
|
||
"any-user",
|
||
person_properties={
|
||
"email": "[email protected]",
|
||
"variant_type": "blue",
|
||
},
|
||
),
|
||
)
|
||
|
||
# Test 5: When leaf-flag="control", intermediate="green", dependent should be false
|
||
self.assertEqual(
|
||
False,
|
||
client.get_feature_flag(
|
||
"dependent-flag",
|
||
"any-user",
|
||
person_properties={
|
||
"email": "[email protected]",
|
||
"variant_type": "green",
|
||
},
|
||
),
|
||
)
|
||
|
||
# Test 6: When leaf-flag="test", intermediate is False, dependent should be false
|
||
self.assertEqual(
|
||
False,
|
||
client.get_feature_flag(
|
||
"dependent-flag",
|
||
"any-user",
|
||
person_properties={"email": "[email protected]", "variant_type": "blue"},
|
||
),
|
||
)
|
||
|
||
def test_matches_dependency_value(self):
|
||
"""Test the matches_dependency_value function logic"""
|
||
from hanzo_insights.feature_flags import matches_dependency_value
|
||
|
||
# String variant matches string exactly (case-sensitive)
|
||
self.assertTrue(matches_dependency_value("control", "control"))
|
||
self.assertTrue(matches_dependency_value("Control", "Control"))
|
||
self.assertFalse(matches_dependency_value("control", "Control"))
|
||
self.assertFalse(matches_dependency_value("Control", "CONTROL"))
|
||
self.assertFalse(matches_dependency_value("control", "test"))
|
||
|
||
# String variant matches boolean true (any variant is truthy)
|
||
self.assertTrue(matches_dependency_value(True, "control"))
|
||
self.assertTrue(matches_dependency_value(True, "test"))
|
||
self.assertFalse(matches_dependency_value(False, "control"))
|
||
|
||
# Boolean matches boolean exactly
|
||
self.assertTrue(matches_dependency_value(True, True))
|
||
self.assertTrue(matches_dependency_value(False, False))
|
||
self.assertFalse(matches_dependency_value(False, True))
|
||
self.assertFalse(matches_dependency_value(True, False))
|
||
|
||
# Empty string doesn't match
|
||
self.assertFalse(matches_dependency_value(True, ""))
|
||
self.assertFalse(matches_dependency_value("control", ""))
|
||
|
||
# Type mismatches
|
||
self.assertFalse(matches_dependency_value(123, "control"))
|
||
self.assertFalse(matches_dependency_value("control", True))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_production_style_multivariate_dependency_chain(
|
||
self, patch_get, patch_flags
|
||
):
|
||
"""Test production-style multivariate dependency chain: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
# Leaf flag: multivariate with fruit variants
|
||
{
|
||
"id": 451,
|
||
"name": "Multivariate Leaf Flag (Base)",
|
||
"key": "multivariate-leaf-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": ["[email protected]"],
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "pineapple",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": ["[email protected]"],
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "mango",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": ["[email protected]"],
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "papaya",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": ["[email protected]"],
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "kiwi",
|
||
},
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0, # Force default to false for unknown emails
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "pineapple", "rollout_percentage": 25},
|
||
{"key": "mango", "rollout_percentage": 25},
|
||
{"key": "papaya", "rollout_percentage": 25},
|
||
{"key": "kiwi", "rollout_percentage": 25},
|
||
]
|
||
},
|
||
},
|
||
},
|
||
# Intermediate flag: multivariate with color variants, depends on fruit
|
||
{
|
||
"id": 467,
|
||
"name": "Multivariate Intermediate Flag (Depends on fruit)",
|
||
"key": "multivariate-intermediate-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "multivariate-leaf-flag",
|
||
"type": "flag",
|
||
"value": "pineapple",
|
||
"operator": "flag_evaluates_to",
|
||
"dependency_chain": ["multivariate-leaf-flag"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "blue",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "multivariate-leaf-flag",
|
||
"type": "flag",
|
||
"value": "mango",
|
||
"operator": "flag_evaluates_to",
|
||
"dependency_chain": ["multivariate-leaf-flag"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "red",
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "blue", "rollout_percentage": 100},
|
||
{"key": "red", "rollout_percentage": 0},
|
||
{"key": "green", "rollout_percentage": 0},
|
||
{"key": "black", "rollout_percentage": 0},
|
||
]
|
||
},
|
||
},
|
||
},
|
||
# Root flag: multivariate with show variants, depends on color
|
||
{
|
||
"id": 468,
|
||
"name": "Multivariate Root Flag (Depends on color)",
|
||
"key": "multivariate-root-flag",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "multivariate-intermediate-flag",
|
||
"type": "flag",
|
||
"value": "blue",
|
||
"operator": "flag_evaluates_to",
|
||
"dependency_chain": [
|
||
"multivariate-leaf-flag",
|
||
"multivariate-intermediate-flag",
|
||
],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "breaking-bad",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "multivariate-intermediate-flag",
|
||
"type": "flag",
|
||
"value": "red",
|
||
"operator": "flag_evaluates_to",
|
||
"dependency_chain": [
|
||
"multivariate-leaf-flag",
|
||
"multivariate-intermediate-flag",
|
||
],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "the-wire",
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "breaking-bad", "rollout_percentage": 100},
|
||
{"key": "the-wire", "rollout_percentage": 0},
|
||
{"key": "game-of-thrones", "rollout_percentage": 0},
|
||
{"key": "the-expanse", "rollout_percentage": 0},
|
||
]
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
# Test successful pineapple -> blue -> breaking-bad chain
|
||
leaf_result = client.get_feature_flag(
|
||
"multivariate-leaf-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
intermediate_result = client.get_feature_flag(
|
||
"multivariate-intermediate-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
root_result = client.get_feature_flag(
|
||
"multivariate-root-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
|
||
self.assertEqual(leaf_result, "pineapple")
|
||
self.assertEqual(intermediate_result, "blue")
|
||
self.assertEqual(root_result, "breaking-bad")
|
||
|
||
# Test successful mango -> red -> the-wire chain
|
||
mango_leaf_result = client.get_feature_flag(
|
||
"multivariate-leaf-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
mango_intermediate_result = client.get_feature_flag(
|
||
"multivariate-intermediate-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
mango_root_result = client.get_feature_flag(
|
||
"multivariate-root-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
|
||
self.assertEqual(mango_leaf_result, "mango")
|
||
self.assertEqual(mango_intermediate_result, "red")
|
||
self.assertEqual(mango_root_result, "the-wire")
|
||
|
||
# Test broken chain - user without matching email gets default/false results
|
||
unknown_leaf_result = client.get_feature_flag(
|
||
"multivariate-leaf-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
unknown_intermediate_result = client.get_feature_flag(
|
||
"multivariate-intermediate-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
unknown_root_result = client.get_feature_flag(
|
||
"multivariate-root-flag",
|
||
"test-user",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
|
||
self.assertEqual(
|
||
unknown_leaf_result, False
|
||
) # No matching email -> null variant -> false
|
||
self.assertEqual(unknown_intermediate_result, False) # Dependency not satisfied
|
||
self.assertEqual(unknown_root_result, False) # Chain broken
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags(self, patch_get, patch_poll):
|
||
patch_get.return_value = GetResponse(
|
||
data={
|
||
"flags": [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Alpha Feature",
|
||
"key": "alpha-feature",
|
||
"active": False,
|
||
},
|
||
],
|
||
"group_type_mapping": {"0": "company"},
|
||
"cohorts": {},
|
||
},
|
||
etag='"abc123"',
|
||
)
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
with freeze_time("2020-01-01T12:01:00.0000Z"):
|
||
client.load_feature_flags()
|
||
self.assertEqual(len(client.feature_flags), 2)
|
||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||
self.assertEqual(client.group_type_mapping, {"0": "company"})
|
||
self.assertEqual(
|
||
client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00"
|
||
)
|
||
self.assertEqual(patch_poll.call_count, 1)
|
||
# Verify ETag is stored
|
||
self.assertEqual(client._flags_etag, '"abc123"')
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_sends_etag_on_subsequent_requests(
|
||
self, patch_get, patch_poll
|
||
):
|
||
"""Test that the ETag is sent in If-None-Match header on subsequent requests"""
|
||
patch_get.return_value = GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
|
||
"group_type_mapping": {},
|
||
"cohorts": {},
|
||
},
|
||
etag='"initial-etag"',
|
||
)
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.load_feature_flags()
|
||
|
||
# First call should have no etag
|
||
first_call_kwargs = patch_get.call_args_list[0][1]
|
||
self.assertIsNone(first_call_kwargs.get("etag"))
|
||
|
||
# Simulate second call
|
||
client._load_feature_flags()
|
||
|
||
# Second call should have the etag
|
||
second_call_kwargs = patch_get.call_args_list[1][1]
|
||
self.assertEqual(second_call_kwargs.get("etag"), '"initial-etag"')
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_304_not_modified(self, patch_get, patch_poll):
|
||
"""Test that 304 Not Modified responses skip flag processing"""
|
||
# First response with flags
|
||
initial_response = GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
|
||
"group_type_mapping": {"0": "company"},
|
||
"cohorts": {},
|
||
},
|
||
etag='"test-etag"',
|
||
)
|
||
# Second response is 304 Not Modified
|
||
not_modified_response = GetResponse(
|
||
data=None,
|
||
etag='"test-etag"',
|
||
not_modified=True,
|
||
)
|
||
patch_get.side_effect = [initial_response, not_modified_response]
|
||
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.load_feature_flags()
|
||
|
||
# Verify initial flags are loaded
|
||
self.assertEqual(len(client.feature_flags), 1)
|
||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||
self.assertEqual(client.group_type_mapping, {"0": "company"})
|
||
|
||
# Second call with 304
|
||
client._load_feature_flags()
|
||
|
||
# Flags should still be the same (not cleared)
|
||
self.assertEqual(len(client.feature_flags), 1)
|
||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||
self.assertEqual(client.group_type_mapping, {"0": "company"})
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_etag_updated_on_new_response(
|
||
self, patch_get, patch_poll
|
||
):
|
||
"""Test that ETag is updated when flags change"""
|
||
patch_get.side_effect = [
|
||
GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
|
||
"group_type_mapping": {},
|
||
"cohorts": {},
|
||
},
|
||
etag='"etag-v1"',
|
||
),
|
||
GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
|
||
"group_type_mapping": {},
|
||
"cohorts": {},
|
||
},
|
||
etag='"etag-v2"',
|
||
),
|
||
]
|
||
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.load_feature_flags()
|
||
self.assertEqual(client._flags_etag, '"etag-v1"')
|
||
|
||
client._load_feature_flags()
|
||
self.assertEqual(client._flags_etag, '"etag-v2"')
|
||
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_clears_etag_when_server_stops_sending(
|
||
self, patch_get, patch_poll
|
||
):
|
||
"""Test that ETag is cleared when server stops sending it"""
|
||
patch_get.side_effect = [
|
||
GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
|
||
"group_type_mapping": {},
|
||
"cohorts": {},
|
||
},
|
||
etag='"etag-v1"',
|
||
),
|
||
GetResponse(
|
||
data={
|
||
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
|
||
"group_type_mapping": {},
|
||
"cohorts": {},
|
||
},
|
||
etag=None, # Server stopped sending ETag
|
||
),
|
||
]
|
||
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.load_feature_flags()
|
||
self.assertEqual(client._flags_etag, '"etag-v1"')
|
||
|
||
client._load_feature_flags()
|
||
self.assertIsNone(client._flags_etag)
|
||
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
|
||
patch_get.side_effect = APIError(401, "Unauthorized")
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
with self.assertLogs("hanzo_insights", level="ERROR") as logs:
|
||
client.load_feature_flags()
|
||
self.assertEqual(
|
||
logs.output[0],
|
||
"ERROR:hanzo_insights:[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://insights.hanzo.ai/docs/api/overview",
|
||
)
|
||
client.debug = True
|
||
self.assertRaises(APIError, client.load_feature_flags)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_enabled_simple(self, patch_get, patch_flags):
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_enabled_simple_is_false(self, patch_get, patch_flags):
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 0,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 0,
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertFalse(client.feature_enabled("beta-feature", "distinct_id"))
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(
|
||
self, patch_get, patch_flags
|
||
):
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": None,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": None,
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_enabled_simple_with_project_api_key(self, patch_get):
|
||
client = Client(project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_feature_enabled_request_multi_variate(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_feature_flag(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "variant-1", "rollout_percentage": 50},
|
||
{"key": "variant-2", "rollout_percentage": 50},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
self.assertEqual(
|
||
client.get_feature_flag("beta-feature", "distinct_id"), "variant-1"
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_feature_enabled_doesnt_exist(self, patch_flags, patch_poll):
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
client.feature_flags = []
|
||
|
||
patch_flags.return_value = {"featureFlags": {}}
|
||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||
|
||
patch_flags.side_effect = APIError(401, "decide error")
|
||
self.assertIsNone(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_personal_api_key_doesnt_exist(self, patch_flags, patch_poll):
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = []
|
||
|
||
patch_flags.return_value = {"featureFlags": {"feature-flag": True}}
|
||
|
||
self.assertTrue(client.feature_enabled("feature-flag", "distinct_id"))
|
||
|
||
@mock.patch("hanzo_insights.client.Poller")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_load_feature_flags_error(self, patch_get, patch_poll):
|
||
def raise_effect():
|
||
raise Exception("http exception")
|
||
|
||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = []
|
||
|
||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_feature_flag_with_variant_overrides(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "second-variant",
|
||
},
|
||
{"rollout_percentage": 50, "variant": "first-variant"},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "first-variant",
|
||
"name": "First Variant",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{
|
||
"key": "second-variant",
|
||
"name": "Second Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
{
|
||
"key": "third-variant",
|
||
"name": "Third Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"beta-feature",
|
||
"test_id",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
"second-variant",
|
||
)
|
||
self.assertEqual(
|
||
client.get_feature_flag("beta-feature", "example_id"), "first-variant"
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_flag_with_clashing_variant_overrides(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "second-variant",
|
||
},
|
||
# since second-variant comes first in the list, it will be the one that gets picked
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "first-variant",
|
||
},
|
||
{"rollout_percentage": 50, "variant": "first-variant"},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "first-variant",
|
||
"name": "First Variant",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{
|
||
"key": "second-variant",
|
||
"name": "Second Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
{
|
||
"key": "third-variant",
|
||
"name": "Third Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"beta-feature",
|
||
"test_id",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
"second-variant",
|
||
)
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"beta-feature",
|
||
"example_id",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
"second-variant",
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_flag_with_invalid_variant_overrides(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "second???",
|
||
},
|
||
{"rollout_percentage": 50, "variant": "first??"},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "first-variant",
|
||
"name": "First Variant",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{
|
||
"key": "second-variant",
|
||
"name": "Second Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
{
|
||
"key": "third-variant",
|
||
"name": "Third Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"beta-feature",
|
||
"test_id",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
"third-variant",
|
||
)
|
||
self.assertEqual(
|
||
client.get_feature_flag("beta-feature", "example_id"), "second-variant"
|
||
)
|
||
# decide not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_conditions_evaluated_in_order(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"order-test": "server-variant"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Order Test Flag",
|
||
"key": "order-test",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"rollout_percentage": 100,
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "@vip.com",
|
||
"operator": "icontains",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "vip-variant",
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "control",
|
||
"name": "Control",
|
||
"rollout_percentage": 100,
|
||
},
|
||
{
|
||
"key": "vip-variant",
|
||
"name": "VIP Variant",
|
||
"rollout_percentage": 0,
|
||
},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
# Even though [email protected] would match the second condition with variant override,
|
||
# they should match the first condition and get control
|
||
result = client.get_feature_flag(
|
||
"order-test",
|
||
"user123",
|
||
person_properties={"email": "[email protected]"},
|
||
)
|
||
self.assertEqual(result, "control")
|
||
|
||
# server not called because this can be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_boolean_feature_flag_payloads_local(self, patch_flags):
|
||
basic_flag = {
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "person-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "region",
|
||
"operator": "exact",
|
||
"value": ["USA"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"payloads": {"true": 300},
|
||
},
|
||
}
|
||
self.client.feature_flags = [basic_flag]
|
||
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||
),
|
||
300,
|
||
)
|
||
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"person-flag",
|
||
"some-distinct-id",
|
||
match_value=True,
|
||
person_properties={"region": "USA"},
|
||
),
|
||
300,
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_boolean_feature_flag_payload_decide(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"person-flag": True},
|
||
"featureFlagPayloads": {"person-flag": 300},
|
||
}
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||
),
|
||
300,
|
||
)
|
||
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"person-flag",
|
||
"some-distinct-id",
|
||
match_value=True,
|
||
person_properties={"region": "USA"},
|
||
send_feature_flag_events=True,
|
||
),
|
||
300,
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 2)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.reset_mock()
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_multivariate_feature_flag_payloads(self, patch_flags):
|
||
multivariate_flag = {
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "beta-feature",
|
||
"active": True,
|
||
"rollout_percentage": 100,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "email",
|
||
"type": "person",
|
||
"value": "[email protected]",
|
||
"operator": "exact",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "second???",
|
||
},
|
||
{"rollout_percentage": 50, "variant": "first??"},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "first-variant",
|
||
"name": "First Variant",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{
|
||
"key": "second-variant",
|
||
"name": "Second Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
{
|
||
"key": "third-variant",
|
||
"name": "Third Variant",
|
||
"rollout_percentage": 25,
|
||
},
|
||
]
|
||
},
|
||
"payloads": {
|
||
"first-variant": '"some-payload"',
|
||
"third-variant": '{"a": "json"}',
|
||
},
|
||
},
|
||
}
|
||
self.client.feature_flags = [multivariate_flag]
|
||
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"beta-feature",
|
||
"test_id",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
{"a": "json"},
|
||
)
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"beta-feature",
|
||
"test_id",
|
||
match_value="third-variant",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
{"a": "json"},
|
||
)
|
||
|
||
# Force different match value
|
||
self.assertEqual(
|
||
self.client.get_feature_flag_payload(
|
||
"beta-feature",
|
||
"test_id",
|
||
match_value="first-variant",
|
||
person_properties={"email": "[email protected]"},
|
||
),
|
||
"some-payload",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition(
|
||
self, patch_get, patch_flags
|
||
):
|
||
"""
|
||
When a flag has multiple conditions and one contains a static cohort,
|
||
the SDK should fallback to API for the entire flag, not just skip that
|
||
condition and evaluate the next one locally.
|
||
|
||
This prevents returning wrong variants when later conditions could match
|
||
locally but the user is actually in the static cohort.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
# Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "multi-condition-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{"key": "id", "value": 999, "type": "cohort"}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "set-1",
|
||
},
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "$geoip_country_code",
|
||
"operator": "exact",
|
||
"value": ["DE"],
|
||
"type": "person",
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "set-8",
|
||
},
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "set-1", "rollout_percentage": 50},
|
||
{"key": "set-8", "rollout_percentage": 50},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
|
||
|
||
# Mock the API response - user is in the static cohort
|
||
patch_flags.return_value = {"featureFlags": {"multi-condition-flag": "set-1"}}
|
||
|
||
result = client.get_feature_flag(
|
||
"multi-condition-flag",
|
||
"test-distinct-id",
|
||
person_properties={"$geoip_country_code": "DE"},
|
||
)
|
||
|
||
# Should return the API result (set-1), not local evaluation (set-8)
|
||
self.assertEqual(result, "set-1")
|
||
|
||
# Verify API was called (fallback occurred)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags):
|
||
"""
|
||
When a flag has bucketing_identifier: "device_id", the device_id should be
|
||
used for hashing instead of distinct_id.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
# This flag uses device_id for bucketing
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Same distinct_id with different device_ids should produce different results
|
||
# (based on rollout percentage, we check consistency)
|
||
result1 = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-123", device_id="device-A"
|
||
)
|
||
result2 = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-123", device_id="device-A"
|
||
)
|
||
|
||
# Same device_id should give consistent results
|
||
self.assertEqual(result1, result2)
|
||
|
||
# No API fallback should occur
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
def test_match_feature_flag_properties_without_bucketing_value_is_deprecated(
|
||
self,
|
||
):
|
||
"""
|
||
match_feature_flag_properties should preserve backward compatibility when
|
||
bucketing_value is omitted, while warning about deprecation.
|
||
"""
|
||
from hanzo_insights.feature_flags import match_feature_flag_properties
|
||
|
||
flag = {
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
|
||
with self.assertWarnsRegex(
|
||
DeprecationWarning, "without bucketing_value is deprecated"
|
||
):
|
||
result = match_feature_flag_properties(
|
||
flag,
|
||
"user-123",
|
||
{},
|
||
device_id="device-123",
|
||
)
|
||
|
||
self.assertTrue(result)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_same_device_different_users_same_result(
|
||
self, patch_flags
|
||
):
|
||
"""
|
||
When a flag uses device_id bucketing, different distinct_ids with the same
|
||
device_id should get the same result.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 50,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Different distinct_ids with the same device_id should get the same result
|
||
result1 = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-A", device_id="shared-device"
|
||
)
|
||
result2 = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-B", device_id="shared-device"
|
||
)
|
||
result3 = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-C", device_id="shared-device"
|
||
)
|
||
|
||
# All should be the same since device_id is the same
|
||
self.assertEqual(result1, result2)
|
||
self.assertEqual(result2, result3)
|
||
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags):
|
||
"""
|
||
When a flag requires device_id for bucketing but none is provided,
|
||
it should fallback to server evaluation.
|
||
"""
|
||
patch_flags.return_value = {"featureFlags": {"device-bucketed-flag": True}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# No device_id provided - should fallback to API
|
||
result = client.get_feature_flag("device-bucketed-flag", "user-123")
|
||
|
||
self.assertTrue(result)
|
||
# API should have been called
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id(
|
||
self, patch_flags
|
||
):
|
||
"""
|
||
When only_evaluate_locally=True and device_id is required but missing,
|
||
should return None instead of falling back to API.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# No device_id + only_evaluate_locally should return None
|
||
result = client.get_feature_flag(
|
||
"device-bucketed-flag", "user-123", only_evaluate_locally=True
|
||
)
|
||
|
||
self.assertIsNone(result)
|
||
# API should NOT have been called
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags):
|
||
"""
|
||
When bucketing_identifier is not set or is 'distinct_id', should use
|
||
distinct_id for hashing (default behavior).
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
# Flag without bucketing_identifier (defaults to distinct_id)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "normal-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 50,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Different distinct_ids should potentially produce different results
|
||
# but same distinct_id should produce same result
|
||
result1 = client.get_feature_flag("normal-flag", "user-A")
|
||
result2 = client.get_feature_flag("normal-flag", "user-A")
|
||
|
||
self.assertEqual(result1, result2)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_with_multivariate_flag(self, patch_flags):
|
||
"""
|
||
Multivariate flag variant selection should use device_id when
|
||
bucketing_identifier is set to device_id.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "multivariate-device-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"multivariate": {
|
||
"variants": [
|
||
{"key": "control", "rollout_percentage": 50},
|
||
{"key": "test", "rollout_percentage": 50},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
# Same device_id should give same variant
|
||
result1 = client.get_feature_flag(
|
||
"multivariate-device-flag", "user-A", device_id="device-1"
|
||
)
|
||
result2 = client.get_feature_flag(
|
||
"multivariate-device-flag", "user-B", device_id="device-1"
|
||
)
|
||
|
||
# Both should get the same variant because device_id is the same
|
||
self.assertEqual(result1, result2)
|
||
self.assertIn(result1, ["control", "test"])
|
||
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_device_id_bucketing_from_context(self, patch_flags):
|
||
"""
|
||
When device_id is not passed as a parameter but is set in the context,
|
||
it should be resolved from context.
|
||
"""
|
||
from hanzo_insights.contexts import new_context, set_context_device_id
|
||
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-bucketed-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Set device_id in context
|
||
with new_context():
|
||
set_context_device_id("context-device-id")
|
||
result = client.get_feature_flag("device-bucketed-flag", "user-123")
|
||
|
||
# Should evaluate locally using the context device_id
|
||
self.assertTrue(result)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_group_flags_ignore_bucketing_identifier(self, patch_flags):
|
||
"""
|
||
Group flags should continue to use the group identifier for hashing,
|
||
regardless of the bucketing_identifier setting.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "group-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"bucketing_identifier": "device_id", # Should be ignored for group flags
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
client.group_type_mapping = {"0": "company"}
|
||
|
||
# Even with bucketing_identifier set to device_id, group flag should use group identifier
|
||
result = client.get_feature_flag(
|
||
"group-flag",
|
||
"user-123",
|
||
groups={"company": "acme-inc"},
|
||
device_id="some-device",
|
||
)
|
||
|
||
self.assertTrue(result)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_group_flag_dependency_receives_device_id(self, patch_flags):
|
||
"""
|
||
Group flag dependency evaluation should receive device_id so dependent
|
||
device_id-bucketed flags can be evaluated locally.
|
||
"""
|
||
patch_flags.return_value = {"featureFlags": {"group-parent-flag": "from-api"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "device-dependent-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"key": "group-parent-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "device-dependent-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["device-dependent-flag"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
client.group_type_mapping = {"0": "company"}
|
||
|
||
result = client.get_feature_flag(
|
||
"group-parent-flag",
|
||
"user-123",
|
||
groups={"company": "acme-inc"},
|
||
device_id="device-123",
|
||
)
|
||
|
||
self.assertTrue(result)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_group_flag_dependency_ignores_device_id_bucketing_identifier(
|
||
self, patch_flags
|
||
):
|
||
"""
|
||
Group flag dependencies should keep bucketing by group key, even when
|
||
the dependent group flag has bucketing_identifier set to device_id.
|
||
"""
|
||
patch_flags.return_value = {"featureFlags": {"parent-group-flag": "from-api"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "child-group-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"key": "parent-group-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"aggregation_group_type_index": 0,
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{
|
||
"key": "child-group-flag",
|
||
"operator": "flag_evaluates_to",
|
||
"value": True,
|
||
"type": "flag",
|
||
"dependency_chain": ["child-group-flag"],
|
||
}
|
||
],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
},
|
||
]
|
||
client.group_type_mapping = {"0": "company"}
|
||
|
||
result = client.get_feature_flag(
|
||
"parent-group-flag",
|
||
"user-123",
|
||
groups={"company": "acme-inc"},
|
||
)
|
||
|
||
self.assertTrue(result)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_device_id_bucketing(self, patch_flags):
|
||
"""
|
||
get_all_flags_and_payloads should properly handle flags with device_id bucketing.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "normal-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"key": "device-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
},
|
||
]
|
||
|
||
# With device_id provided, both flags should be evaluated locally
|
||
result = client.get_all_flags("user-123", device_id="my-device")
|
||
|
||
self.assertEqual(result["normal-flag"], True)
|
||
self.assertEqual(result["device-flag"], True)
|
||
self.assertEqual(patch_flags.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
|
||
self, patch_flags
|
||
):
|
||
"""
|
||
When some flags require device_id but it's not provided, those flags
|
||
should trigger fallback while others can be evaluated locally.
|
||
"""
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"normal-flag": True, "device-flag": "from-api"}
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "normal-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"key": "device-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"bucketing_identifier": "device_id",
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
},
|
||
]
|
||
|
||
# Without device_id, device-flag can't be evaluated locally
|
||
client.get_all_flags("user-123")
|
||
|
||
# Should fallback to API for all flags when any can't be evaluated locally
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
|
||
class TestMatchProperties(unittest.TestCase):
|
||
def property(self, key, value, operator=None):
|
||
result = {"key": key, "value": value}
|
||
if operator is not None:
|
||
result.update({"operator": operator})
|
||
|
||
return result
|
||
|
||
def test_match_properties_exact(self):
|
||
property_a = self.property(key="key", value="value")
|
||
|
||
self.assertTrue(match_property(property_a, {"key": "value"}))
|
||
|
||
self.assertFalse(match_property(property_a, {"key": "value2"}))
|
||
self.assertFalse(match_property(property_a, {"key": ""}))
|
||
self.assertFalse(match_property(property_a, {"key": None}))
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key2": "value"})
|
||
match_property(property_a, {})
|
||
|
||
property_b = self.property(key="key", value="value", operator="exact")
|
||
self.assertTrue(match_property(property_b, {"key": "value"}))
|
||
|
||
self.assertFalse(match_property(property_b, {"key": "value2"}))
|
||
|
||
property_c = self.property(
|
||
key="key", value=["value1", "value2", "value3"], operator="exact"
|
||
)
|
||
self.assertTrue(match_property(property_c, {"key": "value1"}))
|
||
self.assertTrue(match_property(property_c, {"key": "value2"}))
|
||
self.assertTrue(match_property(property_c, {"key": "value3"}))
|
||
|
||
self.assertFalse(match_property(property_c, {"key": "value4"}))
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_c, {"key2": "value"})
|
||
|
||
def test_match_properties_not_in(self):
|
||
property_a = self.property(key="key", value="value", operator="is_not")
|
||
self.assertTrue(match_property(property_a, {"key": "value2"}))
|
||
self.assertTrue(match_property(property_a, {"key": ""}))
|
||
self.assertTrue(match_property(property_a, {"key": None}))
|
||
|
||
property_c = self.property(
|
||
key="key", value=["value1", "value2", "value3"], operator="is_not"
|
||
)
|
||
self.assertTrue(match_property(property_c, {"key": "value4"}))
|
||
self.assertTrue(match_property(property_c, {"key": "value5"}))
|
||
self.assertTrue(match_property(property_c, {"key": "value6"}))
|
||
self.assertTrue(match_property(property_c, {"key": ""}))
|
||
self.assertTrue(match_property(property_c, {"key": None}))
|
||
|
||
self.assertFalse(match_property(property_c, {"key": "value2"}))
|
||
self.assertFalse(match_property(property_c, {"key": "value3"}))
|
||
self.assertFalse(match_property(property_c, {"key": "value1"}))
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key2": "value"})
|
||
match_property(property_c, {"key2": "value1"}) # overrides don't have 'key'
|
||
|
||
def test_match_properties_is_set(self):
|
||
property_a = self.property(key="key", value="is_set", operator="is_set")
|
||
self.assertTrue(match_property(property_a, {"key": "value"}))
|
||
self.assertTrue(match_property(property_a, {"key": "value2"}))
|
||
self.assertTrue(match_property(property_a, {"key": ""}))
|
||
self.assertFalse(match_property(property_a, {"key": None}))
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key2": "value"})
|
||
match_property(property_a, {})
|
||
|
||
def test_match_properties_icontains(self):
|
||
property_a = self.property(key="key", value="valUe", operator="icontains")
|
||
self.assertTrue(match_property(property_a, {"key": "value"}))
|
||
self.assertTrue(match_property(property_a, {"key": "value2"}))
|
||
self.assertTrue(match_property(property_a, {"key": "value3"}))
|
||
self.assertTrue(match_property(property_a, {"key": "vaLue4"}))
|
||
self.assertTrue(match_property(property_a, {"key": "343tfvalue5"}))
|
||
|
||
self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
|
||
self.assertFalse(match_property(property_a, {"key": 123}))
|
||
|
||
property_b = self.property(key="key", value="3", operator="icontains")
|
||
self.assertTrue(match_property(property_b, {"key": "3"}))
|
||
self.assertTrue(match_property(property_b, {"key": 323}))
|
||
self.assertTrue(match_property(property_b, {"key": "val3"}))
|
||
|
||
self.assertFalse(match_property(property_b, {"key": "three"}))
|
||
|
||
def test_match_properties_regex(self):
|
||
property_a = self.property(key="key", value=r"\.com$", operator="regex")
|
||
self.assertTrue(match_property(property_a, {"key": "value.com"}))
|
||
self.assertTrue(match_property(property_a, {"key": "value2.com"}))
|
||
self.assertFalse(match_property(property_a, {"key": "value2com"}))
|
||
|
||
self.assertFalse(match_property(property_a, {"key": ".com343tfvalue5"}))
|
||
self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
|
||
self.assertFalse(match_property(property_a, {"key": 123}))
|
||
self.assertFalse(match_property(property_a, {"key": "valuecom"}))
|
||
self.assertFalse(match_property(property_a, {"key": r"value\com"}))
|
||
|
||
property_b = self.property(key="key", value="3", operator="regex")
|
||
self.assertTrue(match_property(property_b, {"key": "3"}))
|
||
self.assertTrue(match_property(property_b, {"key": 323}))
|
||
self.assertTrue(match_property(property_b, {"key": "val3"}))
|
||
|
||
self.assertFalse(match_property(property_b, {"key": "three"}))
|
||
|
||
# invalid regex
|
||
property_c = self.property(key="key", value="?*", operator="regex")
|
||
self.assertFalse(match_property(property_c, {"key": "value"}))
|
||
self.assertFalse(match_property(property_c, {"key": "value2"}))
|
||
|
||
# non string value
|
||
property_d = self.property(key="key", value=4, operator="regex")
|
||
self.assertTrue(match_property(property_d, {"key": "4"}))
|
||
self.assertTrue(match_property(property_d, {"key": 4}))
|
||
|
||
self.assertFalse(match_property(property_d, {"key": "value"}))
|
||
|
||
def test_match_properties_math_operators(self):
|
||
property_a = self.property(key="key", value=1, operator="gt")
|
||
self.assertTrue(match_property(property_a, {"key": 2}))
|
||
self.assertTrue(match_property(property_a, {"key": 3}))
|
||
|
||
self.assertFalse(match_property(property_a, {"key": 0}))
|
||
self.assertFalse(match_property(property_a, {"key": -1}))
|
||
# now we handle type mismatches so this should be true
|
||
self.assertTrue(match_property(property_a, {"key": "23"}))
|
||
|
||
property_b = self.property(key="key", value=1, operator="lt")
|
||
self.assertTrue(match_property(property_b, {"key": 0}))
|
||
self.assertTrue(match_property(property_b, {"key": -1}))
|
||
self.assertTrue(match_property(property_b, {"key": -3}))
|
||
|
||
self.assertFalse(match_property(property_b, {"key": 1}))
|
||
self.assertFalse(match_property(property_b, {"key": "1"}))
|
||
self.assertFalse(match_property(property_b, {"key": "3"}))
|
||
|
||
property_c = self.property(key="key", value=1, operator="gte")
|
||
self.assertTrue(match_property(property_c, {"key": 1}))
|
||
self.assertTrue(match_property(property_c, {"key": 2}))
|
||
|
||
self.assertFalse(match_property(property_c, {"key": 0}))
|
||
self.assertFalse(match_property(property_c, {"key": -1}))
|
||
# now we handle type mismatches so this should be true
|
||
self.assertTrue(match_property(property_c, {"key": "3"}))
|
||
|
||
property_d = self.property(key="key", value="43", operator="lte")
|
||
self.assertTrue(match_property(property_d, {"key": "41"}))
|
||
self.assertTrue(match_property(property_d, {"key": "42"}))
|
||
self.assertTrue(match_property(property_d, {"key": "43"}))
|
||
|
||
self.assertFalse(match_property(property_d, {"key": "44"}))
|
||
self.assertFalse(match_property(property_d, {"key": 44}))
|
||
self.assertTrue(match_property(property_d, {"key": 42}))
|
||
|
||
property_e = self.property(key="key", value="30", operator="lt")
|
||
self.assertTrue(match_property(property_e, {"key": "29"}))
|
||
|
||
# depending on the type of override, we adjust type comparison
|
||
self.assertTrue(match_property(property_e, {"key": "100"}))
|
||
self.assertFalse(match_property(property_e, {"key": 100}))
|
||
|
||
property_f = self.property(key="key", value="123aloha", operator="gt")
|
||
self.assertFalse(match_property(property_f, {"key": "123"}))
|
||
self.assertFalse(match_property(property_f, {"key": 122}))
|
||
|
||
# this turns into a string comparison
|
||
self.assertTrue(match_property(property_f, {"key": 129}))
|
||
|
||
def test_match_property_date_operators(self):
|
||
property_a = self.property(
|
||
key="key", value="2022-05-01", operator="is_date_before"
|
||
)
|
||
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
|
||
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
|
||
self.assertTrue(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
|
||
self.assertTrue(
|
||
match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)})
|
||
)
|
||
self.assertTrue(
|
||
match_property(
|
||
property_a,
|
||
{
|
||
"key": datetime.datetime(
|
||
2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid")
|
||
)
|
||
},
|
||
)
|
||
)
|
||
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
|
||
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
|
||
|
||
# Can't be a number
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key": 1})
|
||
|
||
# can't be invalid string
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key": "abcdef"})
|
||
|
||
property_b = self.property(
|
||
key="key", value="2022-05-01", operator="is_date_after"
|
||
)
|
||
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
|
||
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
|
||
self.assertTrue(
|
||
match_property(property_b, {"key": datetime.datetime(2022, 5, 30)})
|
||
)
|
||
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
|
||
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
|
||
|
||
# can't be invalid string
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_b, {"key": "abcdef"})
|
||
|
||
# Invalid flag property
|
||
property_c = self.property(key="key", value=1234, operator="is_date_before")
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_c, {"key": 1})
|
||
|
||
# Timezone aware property
|
||
property_d = self.property(
|
||
key="key", value="2022-04-05 12:34:12 +01:00", operator="is_date_before"
|
||
)
|
||
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
|
||
|
||
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
|
||
self.assertTrue(
|
||
match_property(property_d, {"key": "2022-04-05 12:34:11 +01:00"})
|
||
)
|
||
self.assertTrue(
|
||
match_property(property_d, {"key": "2022-04-05 12:34:11 +01:00"})
|
||
)
|
||
|
||
self.assertFalse(
|
||
match_property(property_d, {"key": "2022-04-05 12:34:13 +01:00"})
|
||
)
|
||
|
||
self.assertTrue(
|
||
match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"})
|
||
)
|
||
self.assertFalse(
|
||
match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"})
|
||
)
|
||
|
||
@freeze_time("2022-05-01")
|
||
def test_match_property_relative_date_operators(self):
|
||
property_a = self.property(key="key", value="-6h", operator="is_date_before")
|
||
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
|
||
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
|
||
self.assertTrue(
|
||
match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)})
|
||
)
|
||
# false because date comparison, instead of datetime, so reduces to same date
|
||
self.assertFalse(
|
||
match_property(property_a, {"key": datetime.date(2022, 4, 30)})
|
||
)
|
||
|
||
self.assertFalse(
|
||
match_property(
|
||
property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}
|
||
)
|
||
)
|
||
self.assertTrue(
|
||
match_property(
|
||
property_a,
|
||
{
|
||
"key": datetime.datetime(
|
||
2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid")
|
||
)
|
||
},
|
||
)
|
||
)
|
||
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
|
||
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
|
||
|
||
# Can't be a number
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key": 1})
|
||
|
||
# can't be invalid string
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(property_a, {"key": "abcdef"})
|
||
|
||
property_b = self.property(key="key", value="1h", operator="is_date_after")
|
||
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
|
||
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
|
||
self.assertTrue(
|
||
match_property(property_b, {"key": datetime.datetime(2022, 5, 30)})
|
||
)
|
||
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
|
||
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
|
||
|
||
# can't be invalid string
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
|
||
|
||
# Invalid flag property
|
||
property_c = self.property(key="key", value=1234, operator="is_date_after")
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
self.assertFalse(match_property(property_c, {"key": 1}))
|
||
|
||
# parsed as 1234-05-01 for some reason?
|
||
self.assertTrue(match_property(property_c, {"key": "2022-05-30"}))
|
||
|
||
# # Timezone aware property
|
||
property_d = self.property(key="key", value="12d", operator="is_date_before")
|
||
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
|
||
|
||
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
|
||
self.assertTrue(
|
||
match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"})
|
||
)
|
||
self.assertTrue(
|
||
match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"})
|
||
)
|
||
|
||
self.assertFalse(
|
||
match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"})
|
||
)
|
||
|
||
# Try all possible relative dates
|
||
property_e = self.property(key="key", value="1h", operator="is_date_before")
|
||
self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
|
||
self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))
|
||
|
||
property_f = self.property(key="key", value="-1d", operator="is_date_before")
|
||
self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
|
||
self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))
|
||
|
||
property_g = self.property(key="key", value="1w", operator="is_date_before")
|
||
self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
|
||
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
|
||
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))
|
||
|
||
property_h = self.property(key="key", value="1m", operator="is_date_before")
|
||
self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
|
||
self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))
|
||
|
||
property_i = self.property(key="key", value="1y", operator="is_date_before")
|
||
self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
|
||
self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))
|
||
|
||
property_j = self.property(key="key", value="122h", operator="is_date_after")
|
||
self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
|
||
self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))
|
||
|
||
property_k = self.property(key="key", value="2d", operator="is_date_after")
|
||
self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
|
||
self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
|
||
self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))
|
||
|
||
property_l = self.property(key="key", value="-02w", operator="is_date_after")
|
||
self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
|
||
self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))
|
||
|
||
property_m = self.property(key="key", value="1m", operator="is_date_after")
|
||
self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
|
||
self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))
|
||
|
||
property_n = self.property(key="key", value="1y", operator="is_date_after")
|
||
self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
|
||
self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
|
||
self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
|
||
self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
|
||
self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))
|
||
|
||
def test_none_property_value_with_all_operators(self):
|
||
property_a = self.property(key="key", value="none", operator="is_not")
|
||
self.assertFalse(match_property(property_a, {"key": None}))
|
||
self.assertTrue(match_property(property_a, {"key": "non"}))
|
||
|
||
property_b = self.property(key="key", value=None, operator="is_set")
|
||
self.assertFalse(match_property(property_b, {"key": None}))
|
||
|
||
property_c = self.property(key="key", value="no", operator="icontains")
|
||
self.assertFalse(match_property(property_c, {"key": None}))
|
||
self.assertFalse(match_property(property_c, {"key": "smh"}))
|
||
|
||
property_d = self.property(key="key", value="No", operator="regex")
|
||
self.assertFalse(match_property(property_d, {"key": None}))
|
||
|
||
property_d_lower_case = self.property(key="key", value="no", operator="regex")
|
||
self.assertFalse(match_property(property_d_lower_case, {"key": None}))
|
||
|
||
property_e = self.property(key="key", value=1, operator="gt")
|
||
self.assertFalse(match_property(property_e, {"key": None}))
|
||
|
||
property_f = self.property(key="key", value=1, operator="lt")
|
||
self.assertFalse(match_property(property_f, {"key": None}))
|
||
|
||
property_g = self.property(key="key", value="xyz", operator="gte")
|
||
self.assertFalse(match_property(property_g, {"key": None}))
|
||
|
||
property_h = self.property(key="key", value="Oo", operator="lte")
|
||
self.assertFalse(match_property(property_h, {"key": None}))
|
||
|
||
property_i = self.property(
|
||
key="key", value="2022-05-01", operator="is_date_before"
|
||
)
|
||
self.assertFalse(match_property(property_i, {"key": None}))
|
||
|
||
property_j = self.property(
|
||
key="key", value="2022-05-01", operator="is_date_after"
|
||
)
|
||
self.assertFalse(match_property(property_j, {"key": None}))
|
||
|
||
property_k = self.property(
|
||
key="key", value="2022-05-01", operator="is_date_before"
|
||
)
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
self.assertFalse(match_property(property_k, {"key": "random"}))
|
||
|
||
def test_match_properties_semver_eq(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
# Pre-release suffix is stripped for comparison
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3-alpha.1"}))
|
||
|
||
# Partial versions default missing parts to 0
|
||
prop_partial = self.property(key="version", value="1.2", operator="semver_eq")
|
||
self.assertTrue(match_property(prop_partial, {"version": "1.2.0"}))
|
||
self.assertFalse(match_property(prop_partial, {"version": "1.2.1"}))
|
||
|
||
def test_match_properties_semver_neq(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_neq")
|
||
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertTrue(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
def test_match_properties_semver_gt(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_gt")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.3.0"}))
|
||
self.assertTrue(match_property(prop, {"version": "2.0.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertFalse(match_property(prop, {"version": "0.9.0"}))
|
||
|
||
def test_match_properties_semver_gte(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_gte")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertTrue(match_property(prop, {"version": "2.0.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertFalse(match_property(prop, {"version": "0.9.0"}))
|
||
|
||
def test_match_properties_semver_lt(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_lt")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.1.0"}))
|
||
self.assertTrue(match_property(prop, {"version": "0.9.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
def test_match_properties_semver_lte(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_lte")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertTrue(match_property(prop, {"version": "0.9.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
def test_match_properties_semver_tilde(self):
|
||
# ~1.2.3 means >=1.2.3 <1.3.0
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_tilde")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.5"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.99"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.3.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
def test_match_properties_semver_caret(self):
|
||
# ^1.2.3 means >=1.2.3 <2.0.0
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_caret")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.9.0"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.99.99"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
|
||
self.assertFalse(match_property(prop, {"version": "0.9.0"}))
|
||
|
||
# ^0.2.3 means >=0.2.3 <0.3.0 (leftmost non-zero is minor)
|
||
prop_zero_major = self.property(
|
||
key="version", value="0.2.3", operator="semver_caret"
|
||
)
|
||
self.assertTrue(match_property(prop_zero_major, {"version": "0.2.3"}))
|
||
self.assertTrue(match_property(prop_zero_major, {"version": "0.2.9"}))
|
||
self.assertFalse(match_property(prop_zero_major, {"version": "0.3.0"}))
|
||
self.assertFalse(match_property(prop_zero_major, {"version": "1.0.0"}))
|
||
|
||
# ^0.0.3 means >=0.0.3 <0.0.4 (leftmost non-zero is patch)
|
||
prop_zero_minor = self.property(
|
||
key="version", value="0.0.3", operator="semver_caret"
|
||
)
|
||
self.assertTrue(match_property(prop_zero_minor, {"version": "0.0.3"}))
|
||
self.assertFalse(match_property(prop_zero_minor, {"version": "0.0.4"}))
|
||
self.assertFalse(match_property(prop_zero_minor, {"version": "0.1.0"}))
|
||
|
||
def test_match_properties_semver_wildcard(self):
|
||
# 1.2.* means >=1.2.0 <1.3.0
|
||
prop = self.property(key="version", value="1.2.*", operator="semver_wildcard")
|
||
self.assertTrue(match_property(prop, {"version": "1.2.0"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.5"}))
|
||
self.assertTrue(match_property(prop, {"version": "1.2.99"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.3.0"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.1.9"}))
|
||
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
|
||
|
||
# 1.* means >=1.0.0 <2.0.0
|
||
prop_major = self.property(
|
||
key="version", value="1.*", operator="semver_wildcard"
|
||
)
|
||
self.assertTrue(match_property(prop_major, {"version": "1.0.0"}))
|
||
self.assertTrue(match_property(prop_major, {"version": "1.99.99"}))
|
||
self.assertFalse(match_property(prop_major, {"version": "2.0.0"}))
|
||
self.assertFalse(match_property(prop_major, {"version": "0.9.0"}))
|
||
|
||
def test_match_properties_semver_with_prerelease(self):
|
||
# Pre-release suffixes are stripped before comparison
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_gt")
|
||
self.assertTrue(match_property(prop, {"version": "1.3.0-beta.1"}))
|
||
self.assertFalse(match_property(prop, {"version": "1.2.2-rc.1"}))
|
||
|
||
# Flag value can also have pre-release suffix
|
||
prop_pre = self.property(
|
||
key="version", value="1.2.3-alpha", operator="semver_gte"
|
||
)
|
||
self.assertTrue(match_property(prop_pre, {"version": "1.2.3"}))
|
||
self.assertTrue(match_property(prop_pre, {"version": "2.0.0"}))
|
||
self.assertFalse(match_property(prop_pre, {"version": "1.2.2"}))
|
||
|
||
def test_match_properties_semver_edge_cases(self):
|
||
"""Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats."""
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
|
||
|
||
# v-prefix: "v1.2.3" -> extracts "1.2.3"
|
||
self.assertTrue(match_property(prop, {"version": "v1.2.3"}))
|
||
|
||
# Leading space: " 1.2.3" -> extracts "1.2.3"
|
||
self.assertTrue(match_property(prop, {"version": " 1.2.3"}))
|
||
|
||
# Trailing space: "1.2.3 " -> extracts "1.2.3"
|
||
self.assertTrue(match_property(prop, {"version": "1.2.3 "}))
|
||
|
||
# Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3
|
||
self.assertTrue(match_property(prop, {"version": "01.02.03"}))
|
||
|
||
# Flag value with v-prefix
|
||
prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq")
|
||
self.assertTrue(match_property(prop_v, {"version": "1.2.3"}))
|
||
|
||
# 0.0.0 minimal version
|
||
prop_min = self.property(key="version", value="0.0.0", operator="semver_eq")
|
||
self.assertTrue(match_property(prop_min, {"version": "0.0.0"}))
|
||
|
||
prop_gt_min = self.property(key="version", value="0.0.0", operator="semver_gt")
|
||
self.assertTrue(match_property(prop_gt_min, {"version": "0.0.1"}))
|
||
self.assertFalse(match_property(prop_gt_min, {"version": "0.0.0"}))
|
||
|
||
# 4-part version: regex extracts "1.2.3.4" -> takes first 3 parts
|
||
prop_four = self.property(key="version", value="1.2.3", operator="semver_eq")
|
||
self.assertTrue(match_property(prop_four, {"version": "1.2.3.4"}))
|
||
|
||
# Truly invalid values raise InconclusiveMatchError
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop, {"version": "abc"})
|
||
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop, {"version": ""})
|
||
|
||
# Leading dot: ".1.2.3" -> invalid, empty first component
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop, {"version": ".1.2.3"})
|
||
|
||
# Caret with v-prefix in flag value
|
||
prop_caret_v = self.property(
|
||
key="version", value="v1.2.3", operator="semver_caret"
|
||
)
|
||
self.assertTrue(match_property(prop_caret_v, {"version": "1.5.0"}))
|
||
self.assertFalse(match_property(prop_caret_v, {"version": "2.0.0"}))
|
||
|
||
# Wildcard with v-prefix in property value
|
||
prop_wild = self.property(
|
||
key="version", value="1.2.*", operator="semver_wildcard"
|
||
)
|
||
self.assertTrue(match_property(prop_wild, {"version": "v1.2.5"}))
|
||
self.assertFalse(match_property(prop_wild, {"version": "v1.3.0"}))
|
||
|
||
def test_match_properties_semver_invalid_values(self):
|
||
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
|
||
|
||
# Invalid person property value
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop, {"version": "not-a-version"})
|
||
|
||
# Missing key
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop, {"other_key": "1.2.3"})
|
||
|
||
# None override value returns False (handled before semver logic)
|
||
self.assertFalse(match_property(prop, {"version": None}))
|
||
|
||
# Invalid flag value
|
||
prop_bad = self.property(key="version", value="not-valid", operator="semver_gt")
|
||
with self.assertRaises(InconclusiveMatchError):
|
||
match_property(prop_bad, {"version": "1.2.3"})
|
||
|
||
def test_unknown_operator(self):
|
||
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
|
||
with self.assertRaises(InconclusiveMatchError) as exception_context:
|
||
match_property(property_a, {"key": "random"})
|
||
self.assertEqual(
|
||
str(exception_context.exception), "Unknown operator is_unknown"
|
||
)
|
||
|
||
|
||
class TestRelativeDateParsing(unittest.TestCase):
|
||
def test_invalid_input(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching("1") is None
|
||
assert relative_date_parse_for_feature_flag_matching("1x") is None
|
||
assert relative_date_parse_for_feature_flag_matching("1.2y") is None
|
||
assert relative_date_parse_for_feature_flag_matching("1z") is None
|
||
assert relative_date_parse_for_feature_flag_matching("1s") is None
|
||
assert (
|
||
relative_date_parse_for_feature_flag_matching("123344000.134m") is None
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching("bazinga") is None
|
||
assert relative_date_parse_for_feature_flag_matching("000bello") is None
|
||
assert relative_date_parse_for_feature_flag_matching("000hello") is None
|
||
|
||
assert relative_date_parse_for_feature_flag_matching("000h") is not None
|
||
assert relative_date_parse_for_feature_flag_matching("1000h") is not None
|
||
|
||
def test_overflow(self):
|
||
assert relative_date_parse_for_feature_flag_matching("1000000h") is None
|
||
assert (
|
||
relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
|
||
)
|
||
|
||
def test_hour_parsing(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1h"
|
||
) == datetime.datetime(
|
||
2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2h"
|
||
) == datetime.datetime(
|
||
2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"24h"
|
||
) == datetime.datetime(
|
||
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"30h"
|
||
) == datetime.datetime(
|
||
2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"48h"
|
||
) == datetime.datetime(
|
||
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"24h"
|
||
) == relative_date_parse_for_feature_flag_matching("1d")
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"48h"
|
||
) == relative_date_parse_for_feature_flag_matching("2d")
|
||
|
||
def test_day_parsing(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1d"
|
||
) == datetime.datetime(
|
||
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2d"
|
||
) == datetime.datetime(
|
||
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"7d"
|
||
) == datetime.datetime(
|
||
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"14d"
|
||
) == datetime.datetime(
|
||
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"30d"
|
||
) == datetime.datetime(
|
||
2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"7d"
|
||
) == relative_date_parse_for_feature_flag_matching("1w")
|
||
|
||
def test_week_parsing(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1w"
|
||
) == datetime.datetime(
|
||
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2w"
|
||
) == datetime.datetime(
|
||
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"4w"
|
||
) == datetime.datetime(
|
||
2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"8w"
|
||
) == datetime.datetime(
|
||
2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1m"
|
||
) == datetime.datetime(
|
||
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"4w"
|
||
) != relative_date_parse_for_feature_flag_matching("1m")
|
||
|
||
def test_month_parsing(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1m"
|
||
) == datetime.datetime(
|
||
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2m"
|
||
) == datetime.datetime(
|
||
2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"4m"
|
||
) == datetime.datetime(
|
||
2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"8m"
|
||
) == datetime.datetime(
|
||
2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1y"
|
||
) == datetime.datetime(
|
||
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"12m"
|
||
) == relative_date_parse_for_feature_flag_matching("1y")
|
||
|
||
with freeze_time("2020-04-03T00:00:00"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1m"
|
||
) == datetime.datetime(2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2m"
|
||
) == datetime.datetime(2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"4m"
|
||
) == datetime.datetime(2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"8m"
|
||
) == datetime.datetime(2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
|
||
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1y"
|
||
) == datetime.datetime(2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"12m"
|
||
) == relative_date_parse_for_feature_flag_matching("1y")
|
||
|
||
def test_year_parsing(self):
|
||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"1y"
|
||
) == datetime.datetime(
|
||
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"2y"
|
||
) == datetime.datetime(
|
||
2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"4y"
|
||
) == datetime.datetime(
|
||
2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
assert relative_date_parse_for_feature_flag_matching(
|
||
"8y"
|
||
) == datetime.datetime(
|
||
2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||
)
|
||
|
||
|
||
class TestCaptureCalls(unittest.TestCase):
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_capture_is_called(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "complex-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "region", "value": "USA"}],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id",
|
||
properties={
|
||
"$feature_flag": "complex-flag",
|
||
"$feature_flag_response": True,
|
||
"locally_evaluated": True,
|
||
"$feature/complex-flag": True,
|
||
},
|
||
groups={},
|
||
disable_geoip=None,
|
||
)
|
||
patch_capture.reset_mock()
|
||
|
||
# called again for same user, shouldn't call capture again
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
patch_capture.reset_mock()
|
||
|
||
# called for different user, should call capture again
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id2",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id2",
|
||
properties={
|
||
"$feature_flag": "complex-flag",
|
||
"$feature_flag_response": True,
|
||
"locally_evaluated": True,
|
||
"$feature/complex-flag": True,
|
||
},
|
||
groups={},
|
||
disable_geoip=None,
|
||
)
|
||
patch_capture.reset_mock()
|
||
|
||
# called for different user, but send configuration is false, so should NOT call capture again
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id345",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
send_feature_flag_events=False,
|
||
)
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
patch_capture.reset_mock()
|
||
|
||
# called for different flag, falls back to decide, should call capture again
|
||
self.assertEqual(
|
||
client.get_feature_flag(
|
||
"decide-flag",
|
||
"some-distinct-id2",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
groups={"organization": "org1"},
|
||
),
|
||
"decide-value",
|
||
)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id2",
|
||
properties={
|
||
"$feature_flag": "decide-flag",
|
||
"$feature_flag_response": "decide-value",
|
||
"locally_evaluated": False,
|
||
"$feature/decide-flag": "decide-value",
|
||
},
|
||
groups={"organization": "org1"},
|
||
disable_geoip=None,
|
||
)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {
|
||
"flags": {
|
||
"decide-flag": {
|
||
"key": "decide-flag",
|
||
"enabled": True,
|
||
"variant": "decide-variant",
|
||
"reason": {
|
||
"description": "Matched condition set 1",
|
||
},
|
||
"metadata": {
|
||
"id": 23,
|
||
"version": 42,
|
||
},
|
||
},
|
||
"false-flag": {
|
||
"key": "false-flag",
|
||
"enabled": False,
|
||
"variant": None,
|
||
"reason": {
|
||
"code": "no_matching_condition",
|
||
"description": "No matching condition",
|
||
"condition_index": None,
|
||
},
|
||
"metadata": {
|
||
"id": 1,
|
||
"version": 2,
|
||
},
|
||
},
|
||
},
|
||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||
"evaluatedAt": 1234567890,
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
|
||
self.assertEqual(
|
||
client.get_feature_flag("decide-flag", "some-distinct-id"), "decide-variant"
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id",
|
||
properties={
|
||
"$feature_flag": "decide-flag",
|
||
"$feature_flag_response": "decide-variant",
|
||
"locally_evaluated": False,
|
||
"$feature/decide-flag": "decide-variant",
|
||
"$feature_flag_reason": "Matched condition set 1",
|
||
"$feature_flag_id": 23,
|
||
"$feature_flag_version": 42,
|
||
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||
"$feature_flag_evaluated_at": 1234567890,
|
||
},
|
||
groups={},
|
||
disable_geoip=None,
|
||
)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_capture_is_called_with_flag_details_and_payload(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
patch_flags.return_value = {
|
||
"flags": {
|
||
"decide-flag-with-payload": {
|
||
"key": "decide-flag-with-payload",
|
||
"enabled": True,
|
||
"variant": None,
|
||
"reason": {
|
||
"code": "matched_condition",
|
||
"condition_index": 0,
|
||
"description": "Matched condition set 1",
|
||
},
|
||
"metadata": {
|
||
"id": 23,
|
||
"version": 42,
|
||
"payload": '{"foo": "bar"}',
|
||
},
|
||
}
|
||
},
|
||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||
}
|
||
client = Client(FAKE_TEST_API_KEY)
|
||
|
||
self.assertEqual(
|
||
client.get_feature_flag_payload(
|
||
"decide-flag-with-payload",
|
||
"some-distinct-id",
|
||
send_feature_flag_events=True,
|
||
),
|
||
{"foo": "bar"},
|
||
)
|
||
self.assertEqual(patch_capture.call_count, 1)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id",
|
||
properties={
|
||
"$feature_flag": "decide-flag-with-payload",
|
||
"$feature_flag_response": True,
|
||
"locally_evaluated": False,
|
||
"$feature/decide-flag-with-payload": True,
|
||
"$feature_flag_reason": "Matched condition set 1",
|
||
"$feature_flag_id": 23,
|
||
"$feature_flag_version": 42,
|
||
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||
"$feature_flag_payload": {"foo": "bar"},
|
||
},
|
||
groups={},
|
||
disable_geoip=None,
|
||
)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_capture_is_called_but_does_not_add_all_flags(self, patch_flags):
|
||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "complex-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "region", "value": "USA"}],
|
||
"rollout_percentage": 100,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "Gamma Feature",
|
||
"key": "simple-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
self.assertTrue(
|
||
client.get_feature_flag(
|
||
"complex-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||
)
|
||
)
|
||
|
||
# Grab the capture message that was just added to the queue
|
||
msg = client.queue.get(block=False)
|
||
assert msg["event"] == "$feature_flag_called"
|
||
assert msg["properties"]["$feature_flag"] == "complex-flag"
|
||
assert msg["properties"]["$feature_flag_response"] is True
|
||
assert msg["properties"]["locally_evaluated"] is True
|
||
assert msg["properties"]["$feature/complex-flag"] is True
|
||
assert "$feature/simple-flag" not in msg["properties"]
|
||
assert "$active_feature_flags" not in msg["properties"]
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
"""Test that get_feature_flag_payload does NOT send $feature_flag_called events"""
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"person-flag": True},
|
||
"featureFlagPayloads": {"person-flag": 300},
|
||
}
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "person-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "region", "value": "USA"}],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
"payloads": {"true": '"payload"'},
|
||
},
|
||
}
|
||
]
|
||
|
||
payload = client.get_feature_flag_payload(
|
||
key="person-flag",
|
||
distinct_id="some-distinct-id",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
self.assertIsNotNone(payload)
|
||
self.assertEqual(patch_capture.call_count, 0)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
|
||
self, patch_flags
|
||
):
|
||
"""
|
||
Test that get_feature_flag_payload falls back to API when evaluating
|
||
a flag with static cohorts, similar to get_feature_flag behavior.
|
||
"""
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
|
||
# Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Multi-condition Flag",
|
||
"key": "multi-condition-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [
|
||
{"key": "id", "value": 999, "type": "cohort"}
|
||
],
|
||
"rollout_percentage": 100,
|
||
"variant": "variant-1",
|
||
}
|
||
],
|
||
"multivariate": {
|
||
"variants": [{"key": "variant-1", "rollout_percentage": 100}]
|
||
},
|
||
"payloads": {"variant-1": '{"message": "local-payload"}'},
|
||
},
|
||
}
|
||
]
|
||
client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
|
||
|
||
# Mock the API response - user is in the static cohort
|
||
patch_flags.return_value = {
|
||
"featureFlags": {"multi-condition-flag": "variant-1"},
|
||
"featureFlagPayloads": {"multi-condition-flag": '{"message": "from-api"}'},
|
||
}
|
||
|
||
# Call get_feature_flag_payload without match_value to trigger evaluation
|
||
result = client.get_feature_flag_payload(
|
||
"multi-condition-flag",
|
||
"test-distinct-id",
|
||
)
|
||
|
||
# Should return the API payload, not local payload
|
||
self.assertEqual(result, {"message": "from-api"})
|
||
|
||
# Verify API was called (fallback occurred)
|
||
self.assertEqual(patch_flags.call_count, 1)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
|
||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||
client = Client(
|
||
FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True
|
||
)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "complex-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [{"key": "region", "value": "USA"}],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
"some-distinct-id",
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
disable_geoip=False,
|
||
)
|
||
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id="some-distinct-id",
|
||
properties={
|
||
"$feature_flag": "complex-flag",
|
||
"$feature_flag_response": True,
|
||
"locally_evaluated": True,
|
||
"$feature/complex-flag": True,
|
||
},
|
||
groups={},
|
||
disable_geoip=False,
|
||
)
|
||
|
||
@mock.patch.object(Client, "capture")
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_capture_multiple_users_doesnt_out_of_memory(
|
||
self, patch_flags, patch_capture
|
||
):
|
||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||
# Set on the instance to avoid relying on module-constant patching behavior
|
||
# across Python/runtime implementations.
|
||
client.distinct_ids_feature_flags_reported.max_size = 100
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "complex-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [
|
||
{
|
||
"properties": [],
|
||
"rollout_percentage": 100,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
]
|
||
|
||
for i in range(1000):
|
||
distinct_id = f"some-distinct-id{i}"
|
||
client.get_feature_flag(
|
||
"complex-flag",
|
||
distinct_id,
|
||
person_properties={"region": "USA", "name": "Aloha"},
|
||
)
|
||
patch_capture.assert_called_with(
|
||
"$feature_flag_called",
|
||
distinct_id=distinct_id,
|
||
properties={
|
||
"$feature_flag": "complex-flag",
|
||
"$feature_flag_response": True,
|
||
"locally_evaluated": True,
|
||
"$feature/complex-flag": True,
|
||
},
|
||
groups={},
|
||
disable_geoip=None,
|
||
)
|
||
|
||
self.assertEqual(
|
||
len(client.distinct_ids_feature_flags_reported), i % 100 + 1
|
||
)
|
||
|
||
|
||
class TestConsistency(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
# This ensures no real HTTP POST requests are made
|
||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||
cls.capture_patch.start()
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
cls.capture_patch.stop()
|
||
|
||
def set_fail(self, e, batch):
|
||
"""Mark the failure handler"""
|
||
print("FAIL", e, batch) # noqa: T201
|
||
self.failed = True
|
||
|
||
def setUp(self):
|
||
self.failed = False
|
||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_simple_flag_consistency(self, patch_get):
|
||
self.client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "simple-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 45}],
|
||
},
|
||
}
|
||
]
|
||
|
||
results = [
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
False,
|
||
True,
|
||
False,
|
||
True,
|
||
True,
|
||
]
|
||
|
||
for i in range(1000):
|
||
distinctID = f"distinct_id_{i}"
|
||
|
||
feature_flag_match = self.client.feature_enabled("simple-flag", distinctID)
|
||
|
||
if results[i]:
|
||
self.assertTrue(feature_flag_match)
|
||
else:
|
||
self.assertFalse(feature_flag_match)
|
||
|
||
@mock.patch("hanzo_insights.client.get")
|
||
def test_multivariate_flag_consistency(self, patch_get):
|
||
self.client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"name": "Beta Feature",
|
||
"key": "multivariate-flag",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 55}],
|
||
"multivariate": {
|
||
"variants": [
|
||
{
|
||
"key": "first-variant",
|
||
"name": "First Variant",
|
||
"rollout_percentage": 50,
|
||
},
|
||
{
|
||
"key": "second-variant",
|
||
"name": "Second Variant",
|
||
"rollout_percentage": 20,
|
||
},
|
||
{
|
||
"key": "third-variant",
|
||
"name": "Third Variant",
|
||
"rollout_percentage": 20,
|
||
},
|
||
{
|
||
"key": "fourth-variant",
|
||
"name": "Fourth Variant",
|
||
"rollout_percentage": 5,
|
||
},
|
||
{
|
||
"key": "fifth-variant",
|
||
"name": "Fifth Variant",
|
||
"rollout_percentage": 5,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
results = [
|
||
"second-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"fourth-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"fifth-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fifth-variant",
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"fifth-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"second-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"fifth-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"fifth-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"fourth-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"fifth-variant",
|
||
False,
|
||
"first-variant",
|
||
"fifth-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"fifth-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"second-variant",
|
||
"fifth-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"fifth-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"fourth-variant",
|
||
"fifth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"fourth-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"fourth-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"fourth-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"fifth-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"fourth-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"fifth-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"fifth-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"fifth-variant",
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fifth-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
"fifth-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"fourth-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"third-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"fifth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
"fifth-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"second-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"fifth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"fourth-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"fifth-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
"second-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
False,
|
||
False,
|
||
"first-variant",
|
||
"third-variant",
|
||
"second-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
"first-variant",
|
||
False,
|
||
"third-variant",
|
||
"second-variant",
|
||
"third-variant",
|
||
False,
|
||
False,
|
||
"third-variant",
|
||
"first-variant",
|
||
False,
|
||
"first-variant",
|
||
]
|
||
|
||
for i in range(1000):
|
||
distinctID = f"distinct_id_{i}"
|
||
feature_flag_match = self.client.get_feature_flag(
|
||
"multivariate-flag", distinctID
|
||
)
|
||
|
||
if results[i]:
|
||
self.assertEqual(feature_flag_match, results[i])
|
||
else:
|
||
self.assertFalse(feature_flag_match)
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_feature_flag_case_sensitive(self, mock_decide):
|
||
mock_decide.return_value = {
|
||
"featureFlags": {}
|
||
} # Ensure decide returns empty flags
|
||
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "Beta-Feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
},
|
||
}
|
||
]
|
||
|
||
# Test that flag evaluation is case-sensitive
|
||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
|
||
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_feature_flag_payload_case_sensitive(self, mock_decide):
|
||
mock_decide.return_value = {
|
||
"featureFlags": {"Beta-Feature": True},
|
||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||
}
|
||
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "Beta-Feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
"payloads": {
|
||
"true": {"some": "value"},
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
# Test that payload retrieval is case-sensitive
|
||
self.assertEqual(
|
||
client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"}
|
||
)
|
||
self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
|
||
self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
|
||
mock_decide.return_value = {
|
||
"featureFlags": {"Beta-Feature": True},
|
||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||
}
|
||
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "Beta-Feature",
|
||
"active": True,
|
||
"filters": {
|
||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||
"payloads": {
|
||
"true": {"some": "value"},
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
# Test that flag evaluation and payload retrieval are consistently case-sensitive
|
||
# Only exact match should work
|
||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||
self.assertEqual(
|
||
client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"}
|
||
)
|
||
|
||
# Different cases should not match
|
||
test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
|
||
for case in test_cases:
|
||
self.assertFalse(client.feature_enabled(case, "user1"))
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_with_flag_keys_to_evaluate(self, mock_flags):
|
||
"""Test that get_all_flags with flag_keys_to_evaluate only evaluates specified flags"""
|
||
mock_flags.return_value = {
|
||
"featureFlags": {
|
||
"flag1": "value1",
|
||
"flag2": True,
|
||
}
|
||
}
|
||
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
|
||
# Call get_all_flags with flag_keys_to_evaluate
|
||
result = client.get_all_flags(
|
||
"user123",
|
||
flag_keys_to_evaluate=["flag1", "flag2"],
|
||
person_properties={"region": "USA"},
|
||
)
|
||
|
||
# Verify flags() was called with flag_keys_to_evaluate
|
||
mock_flags.assert_called_once()
|
||
call_args = mock_flags.call_args[1]
|
||
self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag2"])
|
||
self.assertEqual(
|
||
call_args["person_properties"], {"distinct_id": "user123", "region": "USA"}
|
||
)
|
||
|
||
# Check the result
|
||
self.assertEqual(result, {"flag1": "value1", "flag2": True})
|
||
|
||
@mock.patch("hanzo_insights.client.flags")
|
||
def test_get_all_flags_and_payloads_with_flag_keys_to_evaluate(self, mock_flags):
|
||
"""Test that get_all_flags_and_payloads with flag_keys_to_evaluate only evaluates specified flags"""
|
||
mock_flags.return_value = {
|
||
"featureFlags": {
|
||
"flag1": "variant1",
|
||
"flag3": True,
|
||
},
|
||
"featureFlagPayloads": {
|
||
"flag1": {"data": "payload1"},
|
||
"flag3": {"data": "payload3"},
|
||
},
|
||
}
|
||
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
|
||
# Call get_all_flags_and_payloads with flag_keys_to_evaluate
|
||
result = client.get_all_flags_and_payloads(
|
||
"user123",
|
||
flag_keys_to_evaluate=["flag1", "flag3"],
|
||
person_properties={"subscription": "pro"},
|
||
)
|
||
|
||
# Verify flags() was called with flag_keys_to_evaluate
|
||
mock_flags.assert_called_once()
|
||
call_args = mock_flags.call_args[1]
|
||
self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"])
|
||
self.assertEqual(
|
||
call_args["person_properties"],
|
||
{"distinct_id": "user123", "subscription": "pro"},
|
||
)
|
||
|
||
# Check the result
|
||
self.assertEqual(result["featureFlags"], {"flag1": "variant1", "flag3": True})
|
||
self.assertEqual(
|
||
result["featureFlagPayloads"],
|
||
{"flag1": {"data": "payload1"}, "flag3": {"data": "payload3"}},
|
||
)
|
||
|
||
def test_get_all_flags_locally_with_flag_keys_to_evaluate(self):
|
||
"""Test that local evaluation with flag_keys_to_evaluate only evaluates specified flags"""
|
||
client = Client(
|
||
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
||
)
|
||
|
||
# Set up multiple flags
|
||
client.feature_flags = [
|
||
{
|
||
"id": 1,
|
||
"key": "flag1",
|
||
"active": True,
|
||
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
||
},
|
||
{
|
||
"id": 2,
|
||
"key": "flag2",
|
||
"active": True,
|
||
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
||
},
|
||
{
|
||
"id": 3,
|
||
"key": "flag3",
|
||
"active": True,
|
||
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
||
},
|
||
]
|
||
|
||
# Call get_all_flags with flag_keys_to_evaluate
|
||
result = client.get_all_flags(
|
||
"user123",
|
||
flag_keys_to_evaluate=["flag1", "flag3"],
|
||
only_evaluate_locally=True,
|
||
)
|
||
|
||
# Should only return flag1 and flag3
|
||
self.assertEqual(result, {"flag1": True, "flag3": True})
|
||
self.assertNotIn("flag2", result)
|