d42965bba1
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / Frontend Unit Tests (push) Successful in 3m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m33s
CI/CD Pipeline / Unit Tests (push) Successful in 4m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 9m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m17s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""Feature Flag 单元测试。
|
||
|
||
测试 FeatureFlagConfig、InMemoryFeatureFlagStore、RedisFeatureFlagStore 的核心逻辑。
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
# ── 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
|