test_feature.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. from dataclasses import dataclass
  2. from datetime import datetime, timezone
  3. import orjson
  4. import pytest
  5. import yaml
  6. from flagpole import ContextBuilder, EvaluationContext, Feature, InvalidFeatureFlagConfiguration
  7. from flagpole.conditions import ConditionOperatorKind
  8. @dataclass
  9. class SimpleTestContextData:
  10. pass
  11. class TestParseFeatureConfig:
  12. def get_is_true_context_builder(self, is_true_value: bool):
  13. return ContextBuilder().add_context_transformer(lambda _data: dict(is_true=is_true_value))
  14. def test_valid_without_created_at(self):
  15. feature = Feature.from_feature_config_json("foo", '{"owner": "test", "segments":[]}')
  16. assert feature.name == "foo"
  17. assert isinstance(feature.created_at, datetime)
  18. assert feature.segments == []
  19. def test_feature_with_empty_segments(self):
  20. feature = Feature.from_feature_config_json(
  21. "foobar",
  22. """
  23. {
  24. "created_at": "2023-10-12T00:00:00.000Z",
  25. "owner": "test-owner",
  26. "segments": []
  27. }
  28. """,
  29. )
  30. assert feature.name == "foobar"
  31. assert feature.created_at == datetime(2023, 10, 12, tzinfo=timezone.utc)
  32. assert feature.owner == "test-owner"
  33. assert feature.segments == []
  34. assert not feature.match(EvaluationContext(dict()))
  35. def test_feature_with_rollout_zero(self):
  36. feature = Feature.from_feature_config_json(
  37. "foobar",
  38. """
  39. {
  40. "created_at": "2023-10-12T00:00:00.000Z",
  41. "owner": "test-owner",
  42. "segments": [
  43. {
  44. "name": "exclude",
  45. "rollout": 0,
  46. "conditions": [
  47. {
  48. "property": "user_email",
  49. "operator": "equals",
  50. "value": "nope@example.com"
  51. }
  52. ]
  53. },
  54. {
  55. "name": "friends",
  56. "rollout": 100,
  57. "conditions": [
  58. {
  59. "property": "organization_slug",
  60. "operator": "in",
  61. "value": ["acme", "sentry"]
  62. }
  63. ]
  64. }
  65. ]
  66. }
  67. """,
  68. )
  69. exclude_user = {"user_email": "nope@example.com", "organization_slug": "acme"}
  70. assert not feature.match(EvaluationContext(exclude_user))
  71. match_user = {"user_email": "yes@example.com", "organization_slug": "acme"}
  72. assert feature.match(EvaluationContext(match_user))
  73. def test_all_conditions_in_segment(self):
  74. feature = Feature.from_feature_config_json(
  75. "foobar",
  76. """
  77. {
  78. "created_at": "2023-10-12T00:00:00.000Z",
  79. "owner": "test-owner",
  80. "segments": [
  81. {
  82. "name": "multiple conditions",
  83. "rollout": 100,
  84. "conditions": [
  85. {
  86. "property": "user_email",
  87. "operator": "equals",
  88. "value": "yes@example.com"
  89. },
  90. {
  91. "property": "organization_slug",
  92. "operator": "in",
  93. "value": ["acme", "sentry"]
  94. }
  95. ]
  96. }
  97. ]
  98. }
  99. """,
  100. )
  101. exclude_user = {"user_email": "yes@example.com"}
  102. assert not feature.match(EvaluationContext(exclude_user))
  103. match_user = {"user_email": "yes@example.com", "organization_slug": "acme"}
  104. assert feature.match(EvaluationContext(match_user))
  105. def test_valid_with_all_nesting(self):
  106. feature = Feature.from_feature_config_json(
  107. "foobar",
  108. """
  109. {
  110. "created_at": "2023-10-12T00:00:00.000Z",
  111. "owner": "test-owner",
  112. "segments": [{
  113. "name": "segment1",
  114. "rollout": 100,
  115. "conditions": [{
  116. "property": "test_property",
  117. "operator": "in",
  118. "value": ["foobar"]
  119. }]
  120. }]
  121. }
  122. """,
  123. )
  124. assert feature.name == "foobar"
  125. assert len(feature.segments) == 1
  126. assert feature.segments[0].name == "segment1"
  127. assert feature.segments[0].rollout == 100
  128. assert len(feature.segments[0].conditions) == 1
  129. condition = feature.segments[0].conditions[0]
  130. assert condition.property == "test_property"
  131. assert condition.operator
  132. assert condition.operator == ConditionOperatorKind.IN
  133. assert condition.value == ["foobar"]
  134. assert feature.match(EvaluationContext(dict(test_property="foobar")))
  135. assert not feature.match(EvaluationContext(dict(test_property="barfoo")))
  136. def test_invalid_json(self):
  137. with pytest.raises(InvalidFeatureFlagConfiguration):
  138. Feature.from_feature_config_json("foobar", "{")
  139. def test_empty_string_name(self):
  140. with pytest.raises(InvalidFeatureFlagConfiguration) as exception:
  141. Feature.from_feature_config_json("", '{"segments":[]}')
  142. assert "Provided JSON is not a valid feature" in str(exception)
  143. def test_missing_segments(self):
  144. with pytest.raises(InvalidFeatureFlagConfiguration) as exception:
  145. Feature.from_feature_config_json("foo", "{}")
  146. assert "Provided JSON is not a valid feature" in str(exception)
  147. def test_enabled_feature(self):
  148. feature = Feature.from_feature_config_json(
  149. "foo",
  150. """
  151. {
  152. "owner": "test-user",
  153. "segments": [{
  154. "name": "always_pass_segment",
  155. "rollout": 100,
  156. "conditions": [{
  157. "name": "Always true",
  158. "property": "is_true",
  159. "operator": "equals",
  160. "value": true
  161. }]
  162. }]
  163. }
  164. """,
  165. )
  166. context_builder = self.get_is_true_context_builder(is_true_value=True)
  167. assert feature.match(context_builder.build(SimpleTestContextData()))
  168. def test_disabled_feature(self):
  169. feature = Feature.from_feature_config_json(
  170. "foo",
  171. """
  172. {
  173. "owner": "test-user",
  174. "enabled": false,
  175. "segments": [{
  176. "name": "always_pass_segment",
  177. "rollout": 100,
  178. "conditions": [{
  179. "name": "Always true",
  180. "property": "is_true",
  181. "operator": "equals",
  182. "value": true
  183. }]
  184. }]
  185. }
  186. """,
  187. )
  188. context_builder = self.get_is_true_context_builder(is_true_value=True)
  189. assert not feature.match(context_builder.build(SimpleTestContextData()))
  190. def test_dump_yaml(self):
  191. feature = Feature.from_feature_config_json(
  192. "foo",
  193. """
  194. {
  195. "owner": "test-user",
  196. "segments": [{
  197. "name": "always_pass_segment",
  198. "rollout": 100,
  199. "conditions": [{
  200. "name": "Always true",
  201. "property": "is_true",
  202. "operator": "equals",
  203. "value": true
  204. }]
  205. }]
  206. }
  207. """,
  208. )
  209. parsed_json = orjson.loads(feature.json())
  210. parsed_yaml = dict(yaml.safe_load(feature.to_yaml_str()))
  211. assert "foo" in parsed_yaml
  212. parsed_json.pop("name")
  213. assert parsed_yaml["foo"] == parsed_json
  214. features_from_yaml = Feature.from_bulk_yaml(feature.to_yaml_str())
  215. assert features_from_yaml == [feature]