test_feature.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. from dataclasses import dataclass
  2. import jsonschema
  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_feature_with_empty_segments(self):
  15. feature = Feature.from_feature_config_json(
  16. "foobar",
  17. """
  18. {
  19. "created_at": "2023-10-12T00:00:00.000Z",
  20. "owner": "test-owner",
  21. "segments": []
  22. }
  23. """,
  24. )
  25. assert feature.name == "foobar"
  26. assert feature.created_at == "2023-10-12T00:00:00.000Z"
  27. assert feature.owner == "test-owner"
  28. assert feature.segments == []
  29. assert not feature.match(EvaluationContext(dict()))
  30. def test_feature_with_default_rollout(self):
  31. feature = Feature.from_feature_config_json(
  32. "foo",
  33. """
  34. {
  35. "owner": "test-user",
  36. "created_at": "2023-10-12T00:00:00.000Z",
  37. "segments": [{
  38. "name": "always_pass_segment",
  39. "conditions": [{
  40. "name": "Always true",
  41. "property": "is_true",
  42. "operator": "equals",
  43. "value": true
  44. }]
  45. }]
  46. }
  47. """,
  48. )
  49. context_builder = self.get_is_true_context_builder(is_true_value=True)
  50. assert feature.segments[0].rollout == 100
  51. assert feature.match(context_builder.build(SimpleTestContextData()))
  52. def test_feature_with_rollout_zero(self):
  53. feature = Feature.from_feature_config_json(
  54. "foobar",
  55. """
  56. {
  57. "created_at": "2023-10-12T00:00:00.000Z",
  58. "owner": "test-owner",
  59. "segments": [
  60. {
  61. "name": "exclude",
  62. "rollout": 0,
  63. "conditions": [
  64. {
  65. "property": "user_email",
  66. "operator": "equals",
  67. "value": "nope@example.com"
  68. }
  69. ]
  70. },
  71. {
  72. "name": "friends",
  73. "rollout": 100,
  74. "conditions": [
  75. {
  76. "property": "organization_slug",
  77. "operator": "in",
  78. "value": ["acme", "sentry"]
  79. }
  80. ]
  81. }
  82. ]
  83. }
  84. """,
  85. )
  86. exclude_user = {"user_email": "nope@example.com", "organization_slug": "acme"}
  87. assert not feature.match(EvaluationContext(exclude_user))
  88. match_user = {"user_email": "yes@example.com", "organization_slug": "acme"}
  89. assert feature.match(EvaluationContext(match_user))
  90. def test_all_conditions_in_segment(self):
  91. feature = Feature.from_feature_config_json(
  92. "foobar",
  93. """
  94. {
  95. "created_at": "2023-10-12T00:00:00.000Z",
  96. "owner": "test-owner",
  97. "segments": [
  98. {
  99. "name": "multiple conditions",
  100. "rollout": 100,
  101. "conditions": [
  102. {
  103. "property": "user_email",
  104. "operator": "equals",
  105. "value": "yes@example.com"
  106. },
  107. {
  108. "property": "organization_slug",
  109. "operator": "in",
  110. "value": ["acme", "sentry"]
  111. }
  112. ]
  113. }
  114. ]
  115. }
  116. """,
  117. )
  118. exclude_user = {"user_email": "yes@example.com"}
  119. assert not feature.match(EvaluationContext(exclude_user))
  120. match_user = {"user_email": "yes@example.com", "organization_slug": "acme"}
  121. assert feature.match(EvaluationContext(match_user))
  122. def test_valid_with_all_nesting(self):
  123. feature = Feature.from_feature_config_json(
  124. "foobar",
  125. """
  126. {
  127. "created_at": "2023-10-12T00:00:00.000Z",
  128. "owner": "test-owner",
  129. "segments": [{
  130. "name": "segment1",
  131. "rollout": 100,
  132. "conditions": [{
  133. "property": "test_property",
  134. "operator": "in",
  135. "value": ["foobar"]
  136. }]
  137. }]
  138. }
  139. """,
  140. )
  141. assert feature.name == "foobar"
  142. assert len(feature.segments) == 1
  143. assert feature.segments[0].name == "segment1"
  144. assert feature.segments[0].rollout == 100
  145. assert len(feature.segments[0].conditions) == 1
  146. condition = feature.segments[0].conditions[0]
  147. assert condition.property == "test_property"
  148. assert condition.operator
  149. assert condition.operator == ConditionOperatorKind.IN
  150. assert condition.value == ["foobar"]
  151. assert feature.match(EvaluationContext(dict(test_property="foobar")))
  152. assert not feature.match(EvaluationContext(dict(test_property="barfoo")))
  153. def test_invalid_json(self):
  154. with pytest.raises(InvalidFeatureFlagConfiguration):
  155. Feature.from_feature_config_json("foobar", "{")
  156. def test_validate_invalid_schema(self):
  157. config = """
  158. {
  159. "owner": "sentry",
  160. "created_at": "2024-05-14",
  161. "segments": [
  162. {
  163. "name": "",
  164. "rollout": 1,
  165. "conditions": []
  166. }
  167. ]
  168. }
  169. """
  170. feature = Feature.from_feature_config_json("trash", config)
  171. with pytest.raises(jsonschema.ValidationError) as err:
  172. feature.validate()
  173. assert "is too short" in str(err)
  174. config = """
  175. {
  176. "owner": "sentry",
  177. "created_at": "2024-05-14",
  178. "segments": [
  179. {
  180. "name": "allowed orgs",
  181. "rollout": 1,
  182. "conditions": [
  183. {
  184. "property": "organization_slug",
  185. "operator": "contains",
  186. "value": ["derp"]
  187. }
  188. ]
  189. }
  190. ]
  191. }
  192. """
  193. feature = Feature.from_feature_config_json("trash", config)
  194. with pytest.raises(jsonschema.ValidationError) as err:
  195. feature.validate()
  196. assert "'contains'} is not valid" in str(err)
  197. def test_validate_valid(self):
  198. config = """
  199. {
  200. "owner": "sentry",
  201. "created_at": "2024-05-14",
  202. "segments": [
  203. {
  204. "name": "ga",
  205. "rollout": 100,
  206. "conditions": []
  207. }
  208. ]
  209. }
  210. """
  211. feature = Feature.from_feature_config_json("redpaint", config)
  212. assert feature.validate()
  213. def test_empty_string_name(self):
  214. with pytest.raises(InvalidFeatureFlagConfiguration) as exception:
  215. Feature.from_feature_config_json("", '{"segments":[]}')
  216. assert "Feature name is required" in str(exception)
  217. def test_missing_segments(self):
  218. with pytest.raises(InvalidFeatureFlagConfiguration) as exception:
  219. Feature.from_feature_config_json("foo", "{}")
  220. assert "Feature has no segments defined" in str(exception)
  221. def test_invalid_operator_condition(self):
  222. config = """
  223. {
  224. "owner": "sentry",
  225. "segments": [
  226. {
  227. "name": "derp",
  228. "conditions": [
  229. {"property": "user_email", "operator": "trash", "value": 1}
  230. ]
  231. }
  232. ]
  233. }
  234. """
  235. with pytest.raises(InvalidFeatureFlagConfiguration) as exception:
  236. Feature.from_feature_config_json("foo", config)
  237. assert "Provided config_dict is not a valid feature" in str(exception)
  238. def test_enabled_feature(self):
  239. feature = Feature.from_feature_config_json(
  240. "foo",
  241. """
  242. {
  243. "owner": "test-user",
  244. "created_at": "2023-10-12T00:00:00.000Z",
  245. "segments": [{
  246. "name": "always_pass_segment",
  247. "rollout": 100,
  248. "conditions": [{
  249. "name": "Always true",
  250. "property": "is_true",
  251. "operator": "equals",
  252. "value": true
  253. }]
  254. }]
  255. }
  256. """,
  257. )
  258. context_builder = self.get_is_true_context_builder(is_true_value=True)
  259. assert feature.match(context_builder.build(SimpleTestContextData()))
  260. def test_disabled_feature(self):
  261. feature = Feature.from_feature_config_json(
  262. "foo",
  263. """
  264. {
  265. "owner": "test-user",
  266. "enabled": false,
  267. "created_at": "2023-12-12T00:00:00.000Z",
  268. "segments": [{
  269. "name": "always_pass_segment",
  270. "rollout": 100,
  271. "conditions": [{
  272. "name": "Always true",
  273. "property": "is_true",
  274. "operator": "equals",
  275. "value": true
  276. }]
  277. }]
  278. }
  279. """,
  280. )
  281. context_builder = self.get_is_true_context_builder(is_true_value=True)
  282. assert not feature.match(context_builder.build(SimpleTestContextData()))
  283. def test_dump_yaml(self):
  284. feature = Feature.from_feature_config_json(
  285. "foo",
  286. """
  287. {
  288. "owner": "test-user",
  289. "created_at": "2023-12-12T00:00:00.000Z",
  290. "segments": [{
  291. "name": "always_pass_segment",
  292. "rollout": 100,
  293. "conditions": [{
  294. "name": "Always true",
  295. "property": "is_true",
  296. "operator": "equals",
  297. "value": true
  298. }]
  299. }]
  300. }
  301. """,
  302. )
  303. parsed_json = orjson.loads(feature.to_json_str())
  304. parsed_yaml = dict(yaml.safe_load(feature.to_yaml_str()))
  305. assert "foo" in parsed_yaml
  306. assert parsed_yaml == parsed_json
  307. features_from_yaml = Feature.from_bulk_yaml(feature.to_yaml_str())
  308. assert features_from_yaml == [feature]