Coverage for agentos/tests/test_feature_flags.py: 0%
261 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
1"""Tests for agentos.core.feature_flags — FeatureFlagManager, FlagRule, FlagContext."""
3from datetime import UTC, datetime, timedelta
5import pytest
7from agentos.core.feature_flags import (
8 FeatureFlagManager,
9 FlagContext,
10 FlagEvaluation,
11 FlagRule,
12 FlagType,
13 InMemoryFlagStore,
14 create_flag_manager,
15)
17# ============================================================================
18# FlagType
19# ============================================================================
21class TestFlagType:
22 def test_values(self):
23 assert FlagType.BOOLEAN == "boolean"
24 assert FlagType.PERCENTAGE == "percentage"
25 assert FlagType.VARIANT == "variant"
26 assert FlagType.SCHEDULED == "scheduled"
29# ============================================================================
30# FlagRule — to_dict
31# ============================================================================
33class TestFlagRule:
34 def test_defaults(self):
35 rule = FlagRule()
36 assert rule.flag_type == FlagType.BOOLEAN
37 assert rule.enabled is False
38 assert rule.rollout_percentage == 0
39 assert rule.variants == {}
40 assert rule.allowlist_users == set()
41 assert rule.start_time is None
43 def test_to_dict_basic(self):
44 rule = FlagRule(flag_type=FlagType.BOOLEAN, enabled=True)
45 d = rule.to_dict()
46 assert d["flag_type"] == "boolean"
47 assert d["enabled"] is True
48 assert d["rollout_percentage"] == 0
50 def test_to_dict_with_times(self):
51 t1 = datetime(2025, 1, 1, tzinfo=UTC)
52 t2 = datetime(2025, 12, 31, tzinfo=UTC)
53 rule = FlagRule(start_time=t1, end_time=t2)
54 d = rule.to_dict()
55 assert "2025-01-01" in d["start_time"]
56 assert "2025-12-31" in d["end_time"]
59# ============================================================================
60# FlagContext
61# ============================================================================
63class TestFlagContext:
64 def test_defaults(self):
65 ctx = FlagContext()
66 assert ctx.user_id is None
67 assert ctx.tenant_id is None
69 def test_custom(self):
70 ctx = FlagContext(user_id="u1", tenant_id="t1", attributes={"role": "admin"})
71 assert ctx.user_id == "u1"
72 assert ctx.attributes == {"role": "admin"}
75# ============================================================================
76# FlagEvaluation
77# ============================================================================
79class TestFlagEvaluation:
80 def test_enabled(self):
81 e = FlagEvaluation(flag_name="f1", enabled=True, reason="ok")
82 assert e.enabled
83 assert e.flag_name == "f1"
85 def test_disabled(self):
86 e = FlagEvaluation(flag_name="f1", enabled=False, reason="nope")
87 assert not e.enabled
90# ============================================================================
91# InMemoryFlagStore
92# ============================================================================
94class TestInMemoryFlagStore:
95 @pytest.mark.asyncio
96 async def test_set_get(self):
97 store = InMemoryFlagStore()
98 rule = FlagRule(enabled=True)
99 await store.set("f1", rule)
100 assert await store.get("f1") is rule
102 @pytest.mark.asyncio
103 async def test_get_missing(self):
104 store = InMemoryFlagStore()
105 assert await store.get("missing") is None
107 @pytest.mark.asyncio
108 async def test_delete_existing(self):
109 store = InMemoryFlagStore()
110 await store.set("f1", FlagRule())
111 assert await store.delete("f1") is True
112 assert await store.get("f1") is None
114 @pytest.mark.asyncio
115 async def test_delete_missing(self):
116 store = InMemoryFlagStore()
117 assert await store.delete("missing") is False
119 @pytest.mark.asyncio
120 async def test_list(self):
121 store = InMemoryFlagStore()
122 await store.set("a", FlagRule())
123 await store.set("b", FlagRule())
124 names = await store.list()
125 assert sorted(names) == ["a", "b"]
128# ============================================================================
129# FeatureFlagManager — Flag Management
130# ============================================================================
132class TestFeatureFlagManagerManagement:
133 @pytest.mark.asyncio
134 async def test_set_and_get_flag(self):
135 mgr = FeatureFlagManager(InMemoryFlagStore())
136 rule = FlagRule(enabled=True)
137 await mgr.set_flag("f1", rule)
138 assert await mgr.get_flag("f1") is rule
140 @pytest.mark.asyncio
141 async def test_delete_flag(self):
142 mgr = FeatureFlagManager(InMemoryFlagStore())
143 await mgr.set_flag("f1", FlagRule())
144 assert await mgr.delete_flag("f1") is True
145 assert await mgr.get_flag("f1") is None
147 @pytest.mark.asyncio
148 async def test_list_flags(self):
149 mgr = FeatureFlagManager(InMemoryFlagStore())
150 await mgr.set_flag("a", FlagRule())
151 await mgr.set_flag("b", FlagRule())
152 assert sorted(await mgr.list_flags()) == ["a", "b"]
154 @pytest.mark.asyncio
155 async def test_kill_switch(self):
156 mgr = FeatureFlagManager(InMemoryFlagStore())
157 await mgr.set_flag("f1", FlagRule(enabled=True))
158 await mgr.kill_switch("f1")
159 rule = await mgr.get_flag("f1")
160 assert rule.enabled is False
162 @pytest.mark.asyncio
163 async def test_kill_switch_missing(self):
164 mgr = FeatureFlagManager(InMemoryFlagStore())
165 await mgr.kill_switch("missing") # should not raise
168# ============================================================================
169# FeatureFlagManager — is_enabled / evaluate
170# ============================================================================
172class TestFeatureFlagManagerEvaluation:
173 @pytest.mark.asyncio
174 async def test_flag_not_found(self):
175 mgr = FeatureFlagManager(InMemoryFlagStore())
176 assert not await mgr.is_enabled("missing")
178 @pytest.mark.asyncio
179 async def test_boolean_disabled(self):
180 mgr = FeatureFlagManager(InMemoryFlagStore())
181 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=False))
182 assert not await mgr.is_enabled("f1")
184 @pytest.mark.asyncio
185 async def test_boolean_enabled(self):
186 mgr = FeatureFlagManager(InMemoryFlagStore())
187 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=True))
188 assert await mgr.is_enabled("f1")
190 @pytest.mark.asyncio
191 async def test_blocklist_user(self):
192 mgr = FeatureFlagManager(InMemoryFlagStore())
193 await mgr.set_flag("f1", FlagRule(
194 flag_type=FlagType.BOOLEAN, enabled=True,
195 blocklist_users={"u_bad"},
196 ))
197 ctx = FlagContext(user_id="u_bad")
198 assert not await mgr.is_enabled("f1", ctx)
200 @pytest.mark.asyncio
201 async def test_blocklist_tenant(self):
202 mgr = FeatureFlagManager(InMemoryFlagStore())
203 await mgr.set_flag("f1", FlagRule(
204 flag_type=FlagType.BOOLEAN, enabled=True,
205 blocklist_tenants={"t_bad"},
206 ))
207 ctx = FlagContext(tenant_id="t_bad")
208 assert not await mgr.is_enabled("f1", ctx)
210 @pytest.mark.asyncio
211 async def test_allowlist_user_granted(self):
212 mgr = FeatureFlagManager(InMemoryFlagStore())
213 await mgr.set_flag("f1", FlagRule(
214 flag_type=FlagType.BOOLEAN, enabled=True,
215 allowlist_users={"u_good"},
216 ))
217 ctx = FlagContext(user_id="u_good")
218 assert await mgr.is_enabled("f1", ctx)
220 @pytest.mark.asyncio
221 async def test_allowlist_user_not_in(self):
222 mgr = FeatureFlagManager(InMemoryFlagStore())
223 await mgr.set_flag("f1", FlagRule(
224 flag_type=FlagType.BOOLEAN, enabled=True,
225 allowlist_users={"u_good"},
226 ))
227 ctx = FlagContext(user_id="u_other")
228 assert not await mgr.is_enabled("f1", ctx)
230 @pytest.mark.asyncio
231 async def test_allowlist_tenant_not_in(self):
232 mgr = FeatureFlagManager(InMemoryFlagStore())
233 await mgr.set_flag("f1", FlagRule(
234 flag_type=FlagType.BOOLEAN, enabled=True,
235 allowlist_tenants={"t_good"},
236 ))
237 ctx = FlagContext(tenant_id="t_other")
238 assert not await mgr.is_enabled("f1", ctx)
240 @pytest.mark.asyncio
241 async def test_start_time_future(self):
242 mgr = FeatureFlagManager(InMemoryFlagStore())
243 future = datetime.now(UTC) + timedelta(days=30)
244 await mgr.set_flag("f1", FlagRule(
245 flag_type=FlagType.SCHEDULED, enabled=True,
246 start_time=future,
247 ))
248 assert not await mgr.is_enabled("f1")
250 @pytest.mark.asyncio
251 async def test_end_time_past(self):
252 mgr = FeatureFlagManager(InMemoryFlagStore())
253 past = datetime.now(UTC) - timedelta(days=30)
254 await mgr.set_flag("f1", FlagRule(
255 flag_type=FlagType.SCHEDULED, enabled=True,
256 end_time=past,
257 ))
258 assert not await mgr.is_enabled("f1")
260 @pytest.mark.asyncio
261 async def test_scheduled_within_window(self):
262 mgr = FeatureFlagManager(InMemoryFlagStore())
263 past = datetime.now(UTC) - timedelta(days=1)
264 future = datetime.now(UTC) + timedelta(days=1)
265 await mgr.set_flag("f1", FlagRule(
266 flag_type=FlagType.SCHEDULED, enabled=True,
267 start_time=past, end_time=future,
268 ))
269 assert await mgr.is_enabled("f1")
272# ============================================================================
273# FeatureFlagManager — Percentage rollout
274# ============================================================================
276class TestFeatureFlagManagerPercentage:
277 @pytest.mark.asyncio
278 async def test_percentage_zero(self):
279 mgr = FeatureFlagManager(InMemoryFlagStore())
280 await mgr.set_flag("f1", FlagRule(
281 flag_type=FlagType.PERCENTAGE, enabled=True,
282 rollout_percentage=0,
283 ))
284 ctx = FlagContext(user_id="u1")
285 assert not await mgr.is_enabled("f1", ctx)
287 @pytest.mark.asyncio
288 async def test_percentage_hundred(self):
289 mgr = FeatureFlagManager(InMemoryFlagStore())
290 await mgr.set_flag("f1", FlagRule(
291 flag_type=FlagType.PERCENTAGE, enabled=True,
292 rollout_percentage=100,
293 ))
294 ctx = FlagContext(user_id="u1")
295 assert await mgr.is_enabled("f1", ctx)
297 @pytest.mark.asyncio
298 async def test_percentage_deterministic(self):
299 """Same user should always get same result."""
300 mgr = FeatureFlagManager(InMemoryFlagStore())
301 await mgr.set_flag("f1", FlagRule(
302 flag_type=FlagType.PERCENTAGE, enabled=True,
303 rollout_percentage=50,
304 ))
305 ctx = FlagContext(user_id="u1")
306 r1 = await mgr.is_enabled("f1", ctx)
307 r2 = await mgr.is_enabled("f1", ctx)
308 assert r1 == r2
311# ============================================================================
312# FeatureFlagManager — Variant (A/B)
313# ============================================================================
315class TestFeatureFlagManagerVariant:
316 @pytest.mark.asyncio
317 async def test_get_variant(self):
318 mgr = FeatureFlagManager(InMemoryFlagStore())
319 await mgr.set_flag("ab_test", FlagRule(
320 flag_type=FlagType.VARIANT, enabled=True,
321 variants={"control": 50, "treatment": 50},
322 ))
323 ctx = FlagContext(user_id="u1")
324 variant = await mgr.get_variant("ab_test", ctx)
325 assert variant in ("control", "treatment")
327 @pytest.mark.asyncio
328 async def test_variant_deterministic(self):
329 mgr = FeatureFlagManager(InMemoryFlagStore())
330 await mgr.set_flag("ab_test", FlagRule(
331 flag_type=FlagType.VARIANT, enabled=True,
332 variants={"a": 30, "b": 70},
333 ))
334 ctx = FlagContext(user_id="u1")
335 v1 = await mgr.get_variant("ab_test", ctx)
336 v2 = await mgr.get_variant("ab_test", ctx)
337 assert v1 == v2
339 @pytest.mark.asyncio
340 async def test_variant_no_variants(self):
341 mgr = FeatureFlagManager(InMemoryFlagStore())
342 await mgr.set_flag("f1", FlagRule(
343 flag_type=FlagType.VARIANT, enabled=True,
344 ))
345 ctx = FlagContext(user_id="u1")
346 assert await mgr.get_variant("f1", ctx) is None
349# ============================================================================
350# FeatureFlagManager — Audit log
351# ============================================================================
353class TestFeatureFlagManagerAudit:
354 @pytest.mark.asyncio
355 async def test_evaluation_log(self):
356 mgr = FeatureFlagManager(InMemoryFlagStore())
357 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=True))
358 await mgr.is_enabled("f1")
359 log = mgr.get_evaluation_log()
360 assert len(log) == 1
361 assert log[0].enabled
363 @pytest.mark.asyncio
364 async def test_log_limit(self):
365 mgr = FeatureFlagManager(InMemoryFlagStore())
366 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=True))
367 for _ in range(5):
368 await mgr.is_enabled("f1")
369 assert len(mgr.get_evaluation_log(limit=3)) == 3
371 @pytest.mark.asyncio
372 async def test_clear_log(self):
373 mgr = FeatureFlagManager(InMemoryFlagStore())
374 await mgr.set_flag("f1", FlagRule(enabled=True))
375 await mgr.is_enabled("f1")
376 mgr.clear_evaluation_log()
377 assert len(mgr.get_evaluation_log()) == 0
380# ============================================================================
381# FeatureFlagManager — Edge cases
382# ============================================================================
384class TestFeatureFlagManagerEdgeCases:
385 @pytest.mark.asyncio
386 async def test_null_context(self):
387 mgr = FeatureFlagManager(InMemoryFlagStore())
388 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=True))
389 assert await mgr.is_enabled("f1") # no context passed
391 @pytest.mark.asyncio
392 async def test_evaluate_returns_flag_evaluation(self):
393 mgr = FeatureFlagManager(InMemoryFlagStore())
394 await mgr.set_flag("f1", FlagRule(flag_type=FlagType.BOOLEAN, enabled=True))
395 result = await mgr.evaluate("f1")
396 assert isinstance(result, FlagEvaluation)
397 assert result.enabled
399 @pytest.mark.asyncio
400 async def test_allowlist_exclusive_behavior(self):
401 """Non-empty allowlist + user/tenant NOT in it → disabled."""
402 mgr = FeatureFlagManager(InMemoryFlagStore())
403 await mgr.set_flag("f1", FlagRule(
404 flag_type=FlagType.BOOLEAN, enabled=True,
405 allowlist_users={"specific"},
406 allowlist_tenants={"t1"},
407 ))
408 # No user/tenant context
409 ctx = FlagContext()
410 assert not await mgr.is_enabled("f1", ctx)
413# ============================================================================
414# create_flag_manager convenience
415# ============================================================================
417class TestCreateFlagManager:
418 @pytest.mark.asyncio
419 async def test_creates_functional_manager(self):
420 mgr = create_flag_manager()
421 await mgr.set_flag("test", FlagRule(enabled=True))
422 assert await mgr.is_enabled("test")