881eea9195
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 51s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 56s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 9s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
fix(api+ci): DELETE 204响应体全修复 + isort/black整理 + CI环境兼容 + 测试修复
420 lines
16 KiB
Python
420 lines
16 KiB
Python
"""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
|