"""Feature Flag 单元测试。 测试 FeatureFlagConfig、InMemoryFeatureFlagStore、RenderEngineResolver 的核心逻辑。 """ from __future__ import annotations from unittest.mock import MagicMock from packages.adapters.redis.feature_flag_store import ( FeatureFlagConfig, InMemoryFeatureFlagStore, ) # ── FeatureFlagConfig 测试 ────────────────────────────────────────────────── class TestFeatureFlagConfig: """FeatureFlagConfig 核心逻辑测试。""" def test_default_disabled(self): """默认配置为关闭状态。""" config = FeatureFlagConfig(name="test_flag") assert config.enabled is False assert config.percentage == 0 assert config.whitelist == set() assert config.is_active() is False assert config.is_active("user1") is False def test_global_enabled_100_percent(self): """100% + 启用 = 全部命中。""" config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=100) assert config.is_active() is True assert config.is_active("user1") is True assert config.is_active("any_user") is True def test_global_enabled_0_percent_no_whitelist(self): """启用但 0% 且无白名单 = 不命中。""" config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=0) assert config.is_active() is False assert config.is_active("user1") is False def test_whitelist_takes_priority(self): """白名单优先级高于百分比。""" config = FeatureFlagConfig( name="test_flag", enabled=True, percentage=0, whitelist={"user1", "user2"}, ) assert config.is_active("user1") is True assert config.is_active("user2") is True assert config.is_active("user3") is False def test_whitelist_with_percentage(self): """白名单用户即使百分比为0也命中,非白名单按百分比。""" config = FeatureFlagConfig( name="test_flag", enabled=True, percentage=100, # 100% 所有人命中 whitelist={"user1"}, ) assert config.is_active("user1") is True assert config.is_active("user999") is True # 100% 命中 def test_percentage_consistency_same_user(self): """同一用户多次调用结果一致(哈希确定性)。""" config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=50) results = [config.is_active("user_fixed") for _ in range(100)] assert all(r == results[0] for r in results) def test_percentage_different_users_distributed(self): """不同用户分布大致符合百分比(统计检验,宽松阈值)。""" config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=50) active_count = sum(1 for i in range(1000) if config.is_active(f"user_{i}")) # 50% 上下浮动 10% 都算合理 assert 400 <= active_count <= 600, f"Expected ~500, got {active_count}" def test_percentage_boundary_0_and_100(self): """0% 和 100% 的边界情况。""" config_0 = FeatureFlagConfig(name="test", enabled=True, percentage=0) config_100 = FeatureFlagConfig(name="test", enabled=True, percentage=100) for i in range(100): assert config_0.is_active(f"user_{i}") is False assert config_100.is_active(f"user_{i}") is True def test_disabled_ignores_all_other_settings(self): """关闭时忽略白名单和百分比。""" config = FeatureFlagConfig( name="test_flag", enabled=False, percentage=100, whitelist={"user1"}, ) assert config.is_active("user1") is False assert config.is_active() is False def test_none_identifier_with_percentage(self): """无 identifier 时按随机比例(0% 和 100% 是确定的)。""" config_0 = FeatureFlagConfig(name="test", enabled=True, percentage=0) config_100 = FeatureFlagConfig(name="test", enabled=True, percentage=100) assert config_0.is_active(None) is False assert config_100.is_active(None) is True def test_to_dict_and_from_dict(self): """序列化和反序列化对称。""" original = FeatureFlagConfig( name="test_flag", enabled=True, percentage=30, whitelist={"user_a", "user_b", "user_c"}, ) data = original.to_dict() restored = FeatureFlagConfig.from_dict(data) assert restored.name == original.name assert restored.enabled == original.enabled assert restored.percentage == original.percentage assert restored.whitelist == original.whitelist def test_from_dict_with_missing_fields(self): """from_dict 缺失字段时使用默认值。""" config = FeatureFlagConfig.from_dict({"name": "minimal"}) assert config.name == "minimal" assert config.enabled is False assert config.percentage == 0 assert config.whitelist == set() # ── InMemoryFeatureFlagStore 测试 ─────────────────────────────────────────── class TestInMemoryFeatureFlagStore: """内存存储实现测试。""" def test_get_nonexistent_returns_default(self): """获取不存在的 flag 返回默认配置(关闭)。""" store = InMemoryFeatureFlagStore() config = store.get("nonexistent") assert config.name == "nonexistent" assert config.enabled is False def test_set_and_get(self): """设置后可以读取。""" store = InMemoryFeatureFlagStore() config = FeatureFlagConfig(name="test", enabled=True, percentage=50, whitelist={"u1"}) store.set(config) got = store.get("test") assert got.enabled is True assert got.percentage == 50 assert got.whitelist == {"u1"} def test_delete_existing(self): """删除存在的 flag 返回 True。""" store = InMemoryFeatureFlagStore() store.set(FeatureFlagConfig(name="test", enabled=True)) assert store.delete("test") is True assert store.get("test").enabled is False def test_delete_nonexistent(self): """删除不存在的 flag 返回 False。""" store = InMemoryFeatureFlagStore() assert store.delete("nonexistent") is False def test_list_all(self): """列出所有 flag。""" store = InMemoryFeatureFlagStore() store.set(FeatureFlagConfig(name="flag_a", enabled=True)) store.set(FeatureFlagConfig(name="flag_b", percentage=10)) all_flags = store.list_all() assert len(all_flags) == 2 assert "flag_a" in all_flags assert "flag_b" in all_flags assert all_flags["flag_a"].enabled is True def test_is_active_convenience(self): """is_active 便捷方法。""" store = InMemoryFeatureFlagStore() store.set(FeatureFlagConfig(name="render", enabled=True, percentage=0, whitelist={"vip_user"})) assert store.is_active("render", "vip_user") is True assert store.is_active("render", "normal_user") is False assert store.is_active("nonexistent") is False # ── RenderEngineResolver 测试 ─────────────────────────────────────────────── class TestRenderEngineResolver: """渲染引擎选择器测试。""" def test_default_legacy_when_flag_disabled(self): """flag 关闭时使用默认引擎(legacy)。""" store = InMemoryFeatureFlagStore() resolver = self._make_resolver(store=store, default="legacy") assert resolver.get_engine() == "legacy" assert resolver.get_engine("user1") == "legacy" def test_default_unified_when_flag_disabled(self): """flag 关闭但默认值是 unified 时返回 unified。""" store = InMemoryFeatureFlagStore() resolver = self._make_resolver(store=store, default="unified") assert resolver.get_engine() == "unified" def test_whitelist_user_uses_unified(self): """白名单用户走新引擎。""" store = InMemoryFeatureFlagStore() store.set( FeatureFlagConfig( name="render_engine", enabled=True, percentage=0, whitelist={"beta_tester"}, ) ) resolver = self._make_resolver(store=store, default="legacy") assert resolver.get_engine("beta_tester") == "unified" assert resolver.get_engine("normal_user") == "legacy" def test_100_percent_all_unified(self): """100% 时所有用户走新引擎。""" store = InMemoryFeatureFlagStore() store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100)) resolver = self._make_resolver(store=store, default="legacy") for i in range(50): assert resolver.get_engine(f"user_{i}") == "unified" def test_invalid_default_engine_fallback(self): """无效默认值回退到 legacy。""" store = InMemoryFeatureFlagStore() resolver = self._make_resolver(store=store, default="invalid_value") assert resolver.get_engine() == "legacy" def test_should_use_unified_helper(self): """should_use_unified 便捷方法。""" store = InMemoryFeatureFlagStore() store.set( FeatureFlagConfig( name="render_engine", enabled=True, percentage=0, whitelist={"user_a"}, ) ) resolver = self._make_resolver(store=store) assert resolver.should_use_unified("user_a") is True assert resolver.should_use_unified("user_b") is False def test_config_snapshot(self): """配置快照。""" store = InMemoryFeatureFlagStore() store.set( FeatureFlagConfig( name="render_engine", enabled=True, percentage=30, whitelist={"u1", "u2"}, ) ) resolver = self._make_resolver(store=store) snapshot = resolver.get_config_snapshot() assert snapshot["flag_name"] == "render_engine" assert snapshot["enabled"] is True assert snapshot["percentage"] == 30 assert snapshot["whitelist"] == ["u1", "u2"] def test_set_flag_updates_config(self): """通过 set_flag 修改后立即生效。""" store = InMemoryFeatureFlagStore() resolver = self._make_resolver(store=store, default="legacy") # 初始:关闭 assert resolver.get_engine("user1") == "legacy" # 开启 100% resolver.set_flag(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100)) assert resolver.get_engine("user1") == "unified" # 关闭 resolver.set_flag(FeatureFlagConfig(name="render_engine", enabled=False)) assert resolver.get_engine("user1") == "legacy" def test_force_refresh(self): """强制刷新不报错。""" store = InMemoryFeatureFlagStore() resolver = self._make_resolver(store=store) resolver.force_refresh() # 不抛异常即可 def test_does_not_affect_in_flight_tasks(self): """ 热更新不影响在途任务验证: 任务开始时确定引擎,中途配置变更不改变当前任务的引擎选择。 (这是通过"每次调用 get_engine 时读取当前配置"来保证的, 任务开始时调用一次拿到结果,之后不再变化) """ store = InMemoryFeatureFlagStore() store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100)) resolver = self._make_resolver(store=store, default="legacy") # 模拟任务开始时获取引擎 engine_at_start = resolver.get_engine("user1") assert engine_at_start == "unified" # 任务进行中关闭 flag store.set(FeatureFlagConfig(name="render_engine", enabled=False)) resolver.force_refresh() # 在途任务持有的 engine_at_start 仍然是 unified(不随配置变化) assert engine_at_start == "unified" # 新任务会拿到 legacy assert resolver.get_engine("user1") == "legacy" # ── 辅助方法 ── @staticmethod def _make_resolver(store=None, default="legacy"): from apps.worker.video_processing.render_engine_resolver import ( RenderEngineResolver, ) return RenderEngineResolver( default_engine=default, store=store or InMemoryFeatureFlagStore(), refresh_interval=9999, # 测试时禁用自动刷新 ) # ── RedisFeatureFlagStore 降级测试(无 Redis 环境) ─────────────────────── class TestRedisStoreDegradation: """Redis 不可用时的降级行为测试。""" def test_get_returns_default_when_redis_unavailable(self): """Redis 连接失败时返回默认关闭配置,不抛异常。""" from packages.adapters.redis import feature_flag_store as ff_module # 模拟 redis 模块不存在的场景不好做,这里直接测试异常捕获逻辑 store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore) store._redis = MagicMock() store._redis.hgetall.side_effect = ConnectionError("Redis down") store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX store._cache = {} store._cache_ttl = 5.0 import threading store._lock = threading.Lock() config = store.get("render_engine") assert config.enabled is False assert config.name == "render_engine" def test_list_all_returns_empty_on_redis_error(self): """Redis 错误时 list_all 返回空字典。""" from packages.adapters.redis import feature_flag_store as ff_module store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore) store._redis = MagicMock() store._redis.scan.side_effect = ConnectionError("Redis down") store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX store._cache = {} store._cache_ttl = 5.0 import threading store._lock = threading.Lock() result = store.list_all() assert result == {} class TestRedisStoreListAll: """RedisFeatureFlagStore list_all 正常路径测试。""" def _make_store(self): from packages.adapters.redis import feature_flag_store as ff_module store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore) store._redis = MagicMock() store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX store._cache = {} store._cache_ttl = 5.0 import threading store._lock = threading.Lock() return store def test_list_all_scan_with_match_param(self): """list_all 调用 redis.scan 时使用正确的 match 参数名。""" store = self._make_store() prefix = store._key_prefix # 模拟 scan 返回 2 个 key,分 2 次游标 store._redis.scan.side_effect = [ (10, [f"{prefix}render_engine", f"{prefix}other_flag"]), (0, []), ] # 模拟 hgetall 返回配置 store._redis.hgetall.return_value = { b"enabled": b"true", b"percentage": b"50", b"whitelist": b'["user1","user2"]', } result = store.list_all() # 验证 scan 被调用了 2 次(游标遍历) assert store._redis.scan.call_count == 2 # 验证参数名是 match(不是 match_pattern) first_call_kwargs = store._redis.scan.call_args_list[0][1] assert "match" in first_call_kwargs assert "match_pattern" not in first_call_kwargs assert first_call_kwargs["match"] == f"{prefix}*" # 验证返回了 2 个 flag assert len(result) == 2 assert "render_engine" in result assert "other_flag" in result