124 lines
5.2 KiB
Python
124 lines
5.2 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 功能开关(Feature Flags)黄金测试
|
|||
|
|
# 覆盖:默认全开、文件配置生效、损坏/非法回退默认 + 显式 error、
|
|||
|
|
# 未知键如实上报、非 bool 值该键回退、env 路径覆盖、/api/features 端点契约。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from server.agent_core.feature_flags import FEATURE_CATALOG, load_feature_flags
|
|||
|
|
from tests.auth_provider import install_test_auth
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _write(path: Path, data: object) -> str:
|
|||
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|||
|
|
return str(path)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_missing_file_defaults_all_enabled(tmp_path: Path):
|
|||
|
|
"""文件缺失 → 全部默认开启,source=default,无 error。"""
|
|||
|
|
result = load_feature_flags(str(tmp_path / "features.json"))
|
|||
|
|
assert result["source"] == "default"
|
|||
|
|
assert result["error"] is None
|
|||
|
|
assert result["unknown"] == []
|
|||
|
|
assert all(info["enabled"] for info in result["features"].values())
|
|||
|
|
assert set(result["features"]) == set(FEATURE_CATALOG)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_file_config_disables_features(tmp_path: Path):
|
|||
|
|
"""显式 false 隐藏功能;未列出的键保持默认开启;未知键如实上报。"""
|
|||
|
|
path = _write(tmp_path / "features.json", {
|
|||
|
|
"version": 1,
|
|||
|
|
"features": {"drawing": False, "mesh": False, "unknown-thing": True},
|
|||
|
|
})
|
|||
|
|
result = load_feature_flags(path)
|
|||
|
|
assert result["source"] == "file"
|
|||
|
|
assert result["error"] is None
|
|||
|
|
assert result["features"]["drawing"]["enabled"] is False
|
|||
|
|
assert result["features"]["mesh"]["enabled"] is False
|
|||
|
|
assert result["features"]["orders"]["enabled"] is True
|
|||
|
|
assert result["unknown"] == ["unknown-thing"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_corrupt_json_fails_open_with_error(tmp_path: Path):
|
|||
|
|
"""损坏 JSON → fail-open 全部开启 + 显式 error(不锁死界面,不静默吞错)。"""
|
|||
|
|
path = tmp_path / "features.json"
|
|||
|
|
path.write_text("{not-json", encoding="utf-8")
|
|||
|
|
result = load_feature_flags(str(path))
|
|||
|
|
assert result["source"] == "default"
|
|||
|
|
assert result["error"] is not None
|
|||
|
|
assert all(info["enabled"] for info in result["features"].values())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_invalid_shape_and_non_bool_values(tmp_path: Path):
|
|||
|
|
"""顶层结构非法 / features 非对象 / 值非 bool → 相应回退默认并显式报告。"""
|
|||
|
|
bad_version = load_feature_flags(_write(tmp_path / "v.json", {"version": 2, "features": {}}))
|
|||
|
|
assert bad_version["source"] == "default" and bad_version["error"] is not None
|
|||
|
|
|
|||
|
|
bad_section = load_feature_flags(_write(tmp_path / "s.json", {"version": 1, "features": [1]}))
|
|||
|
|
assert bad_section["source"] == "default" and bad_section["error"] is not None
|
|||
|
|
|
|||
|
|
non_bool = load_feature_flags(_write(tmp_path / "b.json", {
|
|||
|
|
"version": 1, "features": {"orders": "no", "drawing": False},
|
|||
|
|
}))
|
|||
|
|
assert non_bool["source"] == "file"
|
|||
|
|
assert non_bool["features"]["orders"]["enabled"] is True # 非 bool 回退默认开启
|
|||
|
|
assert non_bool["features"]["drawing"]["enabled"] is False # 合法 false 仍生效
|
|||
|
|
assert non_bool["error"] is not None and "orders" in non_bool["error"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_env_path_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||
|
|
"""APS_FEATURES_PATH 覆盖默认路径。"""
|
|||
|
|
path = _write(tmp_path / "custom.json", {"version": 1, "features": {"gov": False}})
|
|||
|
|
monkeypatch.setenv("APS_FEATURES_PATH", path)
|
|||
|
|
result = load_feature_flags()
|
|||
|
|
assert result["source"] == "file"
|
|||
|
|
assert result["path"] == path
|
|||
|
|
assert result["features"]["gov"]["enabled"] is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture()
|
|||
|
|
def features_app(tmp_path, monkeypatch):
|
|||
|
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "features.db"))
|
|||
|
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
|||
|
|
from server.db.database import reset_engine
|
|||
|
|
from server.state import store as world_store
|
|||
|
|
|
|||
|
|
install_test_auth(monkeypatch, "tenant-a-000000000000000000000000001")
|
|||
|
|
world_store._stores.clear()
|
|||
|
|
reset_engine()
|
|||
|
|
from server.gateway.app import create_app
|
|||
|
|
|
|||
|
|
app = create_app()
|
|||
|
|
yield app, monkeypatch, tmp_path
|
|||
|
|
reset_engine()
|
|||
|
|
world_store._stores.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_features_endpoint_contract(features_app):
|
|||
|
|
"""/api/features 端点:默认全开契约 + 配置文件生效 + 中文标签齐全。"""
|
|||
|
|
app, monkeypatch, tmp_path = features_app
|
|||
|
|
client = TestClient(app)
|
|||
|
|
assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200
|
|||
|
|
|
|||
|
|
body = client.get("/api/features").json()
|
|||
|
|
assert body["version"] == 1
|
|||
|
|
assert body["source"] == "default"
|
|||
|
|
assert all(info["enabled"] for info in body["features"].values())
|
|||
|
|
assert all(info["label"] for info in body["features"].values())
|
|||
|
|
|
|||
|
|
config = _write(tmp_path / "features.json", {
|
|||
|
|
"version": 1, "features": {"drawing": False, "regen": False},
|
|||
|
|
})
|
|||
|
|
monkeypatch.setenv("APS_FEATURES_PATH", config)
|
|||
|
|
body = client.get("/api/features").json()
|
|||
|
|
assert body["source"] == "file"
|
|||
|
|
assert body["features"]["drawing"]["enabled"] is False
|
|||
|
|
assert body["features"]["regen"]["enabled"] is False
|
|||
|
|
assert body["features"]["orders"]["enabled"] is True
|