c96ebfb4ae
- 新增 FeatureFlagStore 抽象 + Redis/InMemory 双实现
- 支持全局开关 + 白名单 + 百分比切流(MD5哈希一致性)
- 本地缓存 + TTL,减少 Redis 调用
- Redis 不可用时自动降级,不影响业务
- 新增 RenderEngineResolver 渲染引擎选择器
- 环境变量默认 + Redis 运行时覆盖,支持热更新
- 按 user_id 灰度(白名单 > 百分比 > 默认值)
- 惰性刷新,30秒刷新间隔
- 在途任务不受配置变更影响(任务开始时确定引擎)
- 新增 /api/v1/internal/feature-flags 管理接口
- GET 列表/详情、PUT 修改、DELETE 删除
- /{name}/check 端点验证指定标识符是否命中
- 复用内部 API Key 鉴权
- ALLOWED_FLAGS 白名单防误操作
- compose_video 任务接入 RenderEngineResolver
- 从 job.created_by_user_id 获取用户标识
- 替换原环境变量直接读取方式
- 30 个单元测试全绿
- FeatureFlagConfig 判定逻辑(优先级、边界、一致性)
- InMemoryStore CRUD
- RenderEngineResolver(白名单、百分比、默认值、降级、热更新不影响在途任务)
- Redis 异常降级
385 lines
15 KiB
Python
Executable File
385 lines
15 KiB
Python
Executable File
"""Feature Flag 单元测试。
|
|
|
|
测试 FeatureFlagConfig、InMemoryFeatureFlagStore、RenderEngineResolver 的核心逻辑。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
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 连接失败时返回默认关闭配置,不抛异常。"""
|
|
import importlib
|
|
|
|
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 返回空字典。"""
|
|
import importlib
|
|
|
|
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 == {}
|