a65b504a3d
Round two of the from_env-only validation problem. Fifteen checks still lived in the env parsing functions: six enum domains, the redis_url requirement for redis-backed limiter/breaker/cache, cache namespace and TTL, telemetry path and DSN, non-negative structured retries and non-blank scope. from_settings and direct construction bypassed all of them. The five asserts in client.py that claimed config had already validated redis_url and the telemetry targets now hold on every path, so they revert to what CLAUDE.md permits: internal invariant declarations that also narrow the Optional for type checkers. Their comments now name the method that guarantees them, since the previous wording is exactly what went stale. Postgres DSNs built by hand now get the SQLAlchemy +driver suffix stripped the way from_env has always stripped it, with a warning so the rewrite is not silent. The env path strips earlier, so it stays quiet.
557 lines
24 KiB
Python
557 lines
24 KiB
Python
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
||
|
||
import contextlib
|
||
import dataclasses
|
||
|
||
import pytest
|
||
from loguru import logger
|
||
|
||
from polygateway.client import GatewayClient
|
||
from polygateway.config import GatewaySettings, OcrSettings
|
||
|
||
_BASE_ENV = {
|
||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||
"LLM__QWEN__1__API_KEY": "sk-a",
|
||
"LLM__QWEN__1__MODEL": "qwen-max",
|
||
"LLM__QWEN__1__TIMEOUT_S": "120",
|
||
"LLM_MAX_RETRIES": "3",
|
||
"LLM_RETRY_BASE_DELAY": "2.0",
|
||
"LLM_RETRY_MAX_DELAY": "30.0",
|
||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
|
||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
|
||
"PGW_CACHE_BACKEND": "none",
|
||
"PGW_TELEMETRY_BACKEND": "none",
|
||
}
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _captured_warnings():
|
||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||
messages: list[str] = []
|
||
sink_id = logger.add(messages.append, level="WARNING")
|
||
try:
|
||
yield messages
|
||
finally:
|
||
logger.remove(sink_id)
|
||
|
||
|
||
def _env(**overrides):
|
||
env = dict(_BASE_ENV)
|
||
env.update({k: v for k, v in overrides.items() if v is not None})
|
||
for k, v in overrides.items():
|
||
if v is None:
|
||
env.pop(k, None)
|
||
return env
|
||
|
||
|
||
class TestSourceAggregation:
|
||
def test_single_source_parsed(self):
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert len(s.sources) == 1
|
||
src = s.sources[0]
|
||
assert src.name == "qwen_1" and src.provider == "qwen"
|
||
assert src.base_url == "https://gw-a.example/v1" and src.timeout_s == 120.0
|
||
|
||
def test_multi_source_and_optional_fields(self):
|
||
env = _env(
|
||
**{
|
||
"LLM__DEEPSEEK__2__BASE_URL": "https://gw-b.example/v1",
|
||
"LLM__DEEPSEEK__2__API_KEY": "sk-b",
|
||
"LLM__DEEPSEEK__2__MODEL": "deepseek-chat",
|
||
"LLM__DEEPSEEK__2__TIMEOUT_S": "90",
|
||
"LLM__DEEPSEEK__2__RPM": "60",
|
||
"LLM__DEEPSEEK__2__ENABLE_THINKING": "true",
|
||
"LLM__DEEPSEEK__2__MISSING_DONE": "salvage",
|
||
}
|
||
)
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
by_name = {src.name: src for src in s.sources}
|
||
assert set(by_name) == {"qwen_1", "deepseek_2"}
|
||
ds = by_name["deepseek_2"]
|
||
assert ds.rpm == 60 and ds.enable_thinking is True and ds.missing_done == "salvage"
|
||
assert by_name["qwen_1"].enable_thinking is None # 未配置 = 三态 None
|
||
|
||
def test_other_scope_keys_ignored(self):
|
||
env = _env(
|
||
**{
|
||
"OCR__MONKEY__1__BASE_URL": "http://lan/parse",
|
||
"OCR__MONKEY__1__API_KEY": "x",
|
||
"OCR__MONKEY__1__MODEL": "monkey",
|
||
"OCR__MONKEY__1__TIMEOUT_S": "60",
|
||
}
|
||
)
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert len(s.sources) == 1
|
||
|
||
def test_flat_timeout_is_source_default(self):
|
||
env = _env(LLM_TIMEOUT="300", **{"LLM__QWEN__1__TIMEOUT_S": None})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.sources[0].timeout_s == 300.0
|
||
|
||
@pytest.mark.parametrize("missing", ["BASE_URL", "API_KEY", "MODEL"])
|
||
def test_missing_required_source_field_fails(self, missing):
|
||
with pytest.raises(ValueError, match=missing):
|
||
GatewaySettings.from_env("LLM", env=_env(**{f"LLM__QWEN__1__{missing}": None}))
|
||
|
||
def test_unknown_field_fails_loudly(self):
|
||
with pytest.raises(ValueError, match="TEMPRATURE"):
|
||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__QWEN__1__TEMPRATURE": "0.7"}))
|
||
|
||
def test_no_sources_fails(self):
|
||
env = {k: v for k, v in _BASE_ENV.items() if not k.startswith("LLM__")}
|
||
with pytest.raises(ValueError, match="源"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
|
||
|
||
class TestResilienceKeys:
|
||
def test_flat_legacy_keys(self):
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert s.retry.max_attempts == 3 and s.retry.backoff_base_s == 2.0
|
||
assert s.breaker.fail_threshold == 5 and s.breaker.cooldown_s == 60.0
|
||
|
||
def test_scope_keys_override_flat(self):
|
||
env = _env(**{"LLM__RETRY__MAX_ATTEMPTS": "7", "LLM__BREAKER__COOLDOWN_S": "15"})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.retry.max_attempts == 7
|
||
assert s.breaker.cooldown_s == 15.0
|
||
assert s.breaker.fail_threshold == 5 # 未覆盖的仍取平铺键
|
||
|
||
def test_missing_retry_config_fails(self):
|
||
with pytest.raises(ValueError, match="MAX_RETRIES|MAX_ATTEMPTS"):
|
||
GatewaySettings.from_env("LLM", env=_env(LLM_MAX_RETRIES=None))
|
||
|
||
def test_probe_ttl_derived_when_absent(self):
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
# 派生规则(M2 补第三项): max(2 × 最大源 timeout, cooldown, 最大源 timeout + 5)
|
||
assert s.breaker.probe_ttl_s == max(2 * 120.0, 60.0, 120.0 + 5)
|
||
s2 = GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "300"}))
|
||
assert s2.breaker.probe_ttl_s == 300.0
|
||
|
||
def test_probe_ttl_derivation_third_term_wins(self):
|
||
# timeout=4、cooldown=2 → max(8, 2, 9) = 9;守卫 9 ≥ 4+5 恰好成立不报错
|
||
env = _env(
|
||
LLM_CIRCUIT_BREAKER_COOLDOWN="2",
|
||
**{"LLM__QWEN__1__TIMEOUT_S": "4"},
|
||
)
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.breaker.probe_ttl_s == 9.0
|
||
|
||
def test_explicit_probe_ttl_below_guard_rejected(self):
|
||
# 守卫: probe_ttl_s ≥ max(timeout_s) + 5(CHS container 语义,M2 设计 §3)
|
||
with pytest.raises(ValueError, match="probe"):
|
||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))
|
||
|
||
def test_selector_and_quota_full(self):
|
||
# M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert s.selector == "health_aware" and s.quota_full == "wait"
|
||
assert (
|
||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "round_robin"})).selector
|
||
== "round_robin"
|
||
)
|
||
s2 = GatewaySettings.from_env(
|
||
"LLM", env=_env(**{"LLM__SELECTOR": "least_inflight", "LLM__QUOTA_FULL": "fail_fast"})
|
||
)
|
||
assert s2.selector == "least_inflight" and s2.quota_full == "fail_fast"
|
||
with pytest.raises(ValueError):
|
||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__SELECTOR": "random"}))
|
||
|
||
def test_global_limits(self):
|
||
env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "8", "LLM__GLOBAL__RPM": "120"})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.global_limits.max_concurrency == 8 and s.global_limits.rpm == 120
|
||
assert s.global_limits.tpm == 0
|
||
|
||
|
||
class TestAssemblyGuards:
|
||
def test_structured_retries_default_two(self):
|
||
# M2.5 迭代 4: 缺省重问 1→2(instructor 缺省 3 的保守版;P6 阶梯死亡 3.2% 实证)
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert s.structured_max_retries == 2
|
||
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
|
||
assert s2.structured_max_retries == 0
|
||
|
||
def test_cache_requires_namespace_and_ttl(self):
|
||
env = _env(PGW_CACHE_BACKEND="memory")
|
||
with pytest.raises(ValueError, match="NAMESPACE"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
env2 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="0")
|
||
with pytest.raises(ValueError, match="TTL"):
|
||
GatewaySettings.from_env("LLM", env=env2)
|
||
env3 = _env(PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
|
||
s = GatewaySettings.from_env("LLM", env=env3)
|
||
assert s.cache_namespace == "proj" and s.cache_ttl_s == 3600
|
||
|
||
def test_redis_cache_requires_url(self):
|
||
env = _env(PGW_CACHE_BACKEND="redis", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600")
|
||
with pytest.raises(ValueError, match="REDIS_URL"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
|
||
def test_sqlite_telemetry_requires_path(self):
|
||
env = _env(PGW_TELEMETRY_BACKEND="sqlite")
|
||
with pytest.raises(ValueError, match="SQLITE_PATH"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
|
||
def test_timeout_must_fit_lease_ttl(self):
|
||
env = _env(PGW_LEASE_TTL_S="60", **{"LLM__QWEN__1__TIMEOUT_S": "120"})
|
||
with pytest.raises(ValueError, match="租约|lease"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
|
||
def test_effective_breaker_threshold_auto_raised(self):
|
||
env = _env(**{"LLM__QWEN__1__MAX_CONCURRENCY": "8"})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
# 有效阈值 = max(配置值 5, 源级并发 8 × 2) = 16(M2.5: 抬升只看源级)
|
||
assert s.breaker.fail_threshold == 16
|
||
|
||
def test_breaker_threshold_not_raised_by_global_concurrency(self):
|
||
# M2.5 病灶 2 回归: 全局并发不再抬升阈值(M2 曾 max(5, 100×2)=200 使熔断失灵)
|
||
env = _env(**{"LLM__GLOBAL__MAX_CONCURRENCY": "100", "LLM__GLOBAL__RPM": "600"})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.breaker.fail_threshold == 5
|
||
|
||
def test_breaker_rate_channel_defaults_and_overrides(self):
|
||
# M2.5 失败率通道参数: 库缺省(韧性参数缺省先例)与显式覆盖
|
||
s = GatewaySettings.from_env("LLM", env=_env())
|
||
assert s.breaker.min_calls == 10
|
||
assert s.breaker.fail_rate == pytest.approx(0.6)
|
||
assert s.breaker.window_s == pytest.approx(60.0)
|
||
assert s.breaker.max_cooldown_s == pytest.approx(300.0) # max(300, cooldown 60)
|
||
env = _env(
|
||
**{
|
||
"LLM__BREAKER__MIN_CALLS": "20",
|
||
"LLM__BREAKER__FAIL_RATE": "0.5",
|
||
"LLM__BREAKER__WINDOW_S": "30",
|
||
"LLM__BREAKER__MAX_COOLDOWN_S": "600",
|
||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "400",
|
||
}
|
||
)
|
||
s2 = GatewaySettings.from_env("LLM", env=env)
|
||
assert s2.breaker.min_calls == 20 and s2.breaker.fail_rate == pytest.approx(0.5)
|
||
assert s2.breaker.window_s == pytest.approx(30.0)
|
||
assert s2.breaker.max_cooldown_s == pytest.approx(600.0)
|
||
|
||
def test_breaker_max_cooldown_floor_follows_cooldown(self):
|
||
# 缺省封顶 = max(300, cooldown): 冷却 400s 时封顶随之 400s
|
||
env = _env(**{"LLM_CIRCUIT_BREAKER_COOLDOWN": "400"})
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.breaker.max_cooldown_s == pytest.approx(400.0)
|
||
|
||
def test_redis_governance_backend_requires_url(self):
|
||
"""M2 解禁 redis 后端: 取 redis 时 REDIS_URL 必在,缺则装配报错。"""
|
||
env = _env(
|
||
PGW_LIMITER_BACKEND="redis",
|
||
PGW_BREAKER_BACKEND="redis",
|
||
REDIS_URL="redis://:pw@10.0.0.1:6379/3",
|
||
)
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.limiter_backend == "redis" and s.breaker_backend == "redis"
|
||
with pytest.raises(ValueError, match="REDIS_URL"):
|
||
GatewaySettings.from_env("LLM", env=_env(PGW_LIMITER_BACKEND="redis"))
|
||
|
||
def test_postgres_telemetry_requires_dsn_and_strips_driver_suffix(self):
|
||
"""M2: telemetry=postgres 需 PGW_TELEMETRY_PG_DSN;SQLAlchemy 风格驱动后缀剥离。"""
|
||
with pytest.raises(ValueError, match="PG_DSN"):
|
||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="postgres"))
|
||
env = _env(
|
||
PGW_TELEMETRY_BACKEND="postgres",
|
||
PGW_TELEMETRY_PG_DSN="postgresql+psycopg://u:p@h:5432/polygateway",
|
||
)
|
||
s = GatewaySettings.from_env("LLM", env=env)
|
||
assert s.telemetry_pg_dsn == "postgresql://u:p@h:5432/polygateway"
|
||
env2 = _env(
|
||
PGW_TELEMETRY_BACKEND="postgres",
|
||
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u:p@h:5432/polygateway",
|
||
)
|
||
assert (
|
||
GatewaySettings.from_env("LLM", env=env2).telemetry_pg_dsn
|
||
== "postgresql://u:p@h:5432/polygateway"
|
||
)
|
||
|
||
def test_telemetry_backend_whitelist(self):
|
||
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
||
|
||
def test_pricing_path_optional(self):
|
||
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
||
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
||
assert s.pricing_path == "conf/prices.json"
|
||
|
||
def test_stall_window_must_cover_ttft(self):
|
||
"""守卫: stall_window_s ≥ 最大 ttft_timeout_s(防误判卡死,ARCH §7.3)。"""
|
||
env = _env(
|
||
**{
|
||
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||
"LLM__BACKPRESSURE__STALL_WINDOW_S": "20",
|
||
}
|
||
)
|
||
with pytest.raises(ValueError, match="stall"):
|
||
GatewaySettings.from_env("LLM", env=env)
|
||
env_ok = dict(env)
|
||
env_ok["LLM__BACKPRESSURE__STALL_WINDOW_S"] = "60"
|
||
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
|
||
|
||
|
||
class TestOcrSettings:
|
||
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
|
||
|
||
_OCR_ENV = {
|
||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||
"OCR__MONKEY__1__API_KEY": "none", # 无鉴权占位惯例
|
||
"OCR__MONKEY__1__MODEL": "monkey-ocr",
|
||
"OCR__MONKEY__1__TIMEOUT_S": "120",
|
||
"LLM_MAX_RETRIES": "3",
|
||
"LLM_RETRY_BASE_DELAY": "2.0",
|
||
"LLM_RETRY_MAX_DELAY": "30.0",
|
||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
|
||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
|
||
"PGW_CACHE_BACKEND": "none",
|
||
"PGW_TELEMETRY_BACKEND": "none",
|
||
}
|
||
|
||
def test_minimal_ocr_scope(self):
|
||
from polygateway.config import OcrSettings
|
||
|
||
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_ENV))
|
||
gw = settings.gateway
|
||
assert gw.scope == "ocr" # GatewaySettings 统一小写归一(既有约定)
|
||
assert gw.sources[0].name == "monkey_1"
|
||
assert gw.sources[0].provider == "monkey"
|
||
assert gw.sources[0].trust_env is True
|
||
|
||
def test_scope_resilience_override(self):
|
||
from polygateway.config import OcrSettings
|
||
|
||
env = dict(self._OCR_ENV)
|
||
env["OCR__RETRY__MAX_ATTEMPTS"] = "5"
|
||
env["OCR__MONKEY__1__TRUST_ENV"] = "false"
|
||
settings = OcrSettings.from_env("OCR", env=env)
|
||
assert settings.gateway.retry.max_attempts == 5
|
||
assert settings.gateway.sources[0].trust_env is False
|
||
|
||
def test_missing_base_url_fails(self):
|
||
from polygateway.config import OcrSettings
|
||
|
||
env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
|
||
with pytest.raises(ValueError):
|
||
OcrSettings.from_env("OCR", env=env)
|
||
|
||
|
||
class TestCrossFieldInvariants:
|
||
"""四条跨字段不变量必须在**任何**构造路径上生效(设计 2026-07-29)。
|
||
|
||
这些约束单看一个字段都合法,组合起来才非法,因此 types.py 各子配置的
|
||
__post_init__ 看不见——只能由聚合层 GatewaySettings 把关。守卫若只挂在
|
||
from_env 上,from_settings 这条同等官方的装配路(CLAUDE.md §4.5)就能
|
||
装出违反不变量的配置,类会存在于自己 docstring 声称不可能的状态。
|
||
|
||
每条不变量测两侧: 越界必拒、边界值(恰好相等)必过——收紧的是错的组合,
|
||
不是所有直接构造。
|
||
"""
|
||
|
||
def _base(self, **overrides) -> GatewaySettings:
|
||
return GatewaySettings.from_env("LLM", env=_env(**overrides))
|
||
|
||
def _with_watchdog(self) -> GatewaySettings:
|
||
"""带看门狗的基准: TTFT/inter-token 成对配置才满足 SourceConfig 不变式。"""
|
||
return self._base(
|
||
**{
|
||
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||
"LLM__BACKPRESSURE__STALL_WINDOW_S": "300",
|
||
}
|
||
)
|
||
|
||
# —— 源超时 ≤ permit 租约 TTL(ARCH §7.3: 防租约先于请求过期,并发悄悄超配额)——
|
||
|
||
def test_lease_rejects_timeout_above_ttl_on_direct_construction(self):
|
||
base = self._base() # 源 timeout_s=120
|
||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||
dataclasses.replace(base, lease_ttl_s=1.0)
|
||
|
||
def test_lease_accepts_timeout_equal_to_ttl(self):
|
||
base = self._base()
|
||
assert dataclasses.replace(base, lease_ttl_s=120.0).lease_ttl_s == 120.0
|
||
|
||
# —— stall 窗口 ≥ 最大源 TTFT(ARCH §7.3: 防正常慢首包被误判卡死掐断)——
|
||
|
||
def test_stall_rejects_window_below_max_ttft_on_direct_construction(self):
|
||
base = self._with_watchdog() # 源 ttft_timeout_s=30
|
||
narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
|
||
with pytest.raises(ValueError, match="stall_window_s"):
|
||
dataclasses.replace(base, backpressure=narrowed)
|
||
|
||
def test_stall_accepts_window_equal_to_max_ttft(self):
|
||
base = self._with_watchdog()
|
||
exact = dataclasses.replace(base.backpressure, stall_window_s=30.0)
|
||
assert dataclasses.replace(base, backpressure=exact).backpressure.stall_window_s == 30.0
|
||
|
||
# —— 探针租约 ≥ 最慢源超时 + 5(M2 设计 §3: 防半开探针在途即被接管)——
|
||
|
||
def test_probe_rejects_ttl_below_floor_on_direct_construction(self):
|
||
base = self._base() # 最慢 timeout_s=120,故下限 125
|
||
shortened = dataclasses.replace(base.breaker, probe_ttl_s=100.0)
|
||
with pytest.raises(ValueError, match="probe_ttl_s"):
|
||
dataclasses.replace(base, breaker=shortened)
|
||
|
||
def test_probe_accepts_ttl_at_floor(self):
|
||
base = self._base()
|
||
at_floor = dataclasses.replace(base.breaker, probe_ttl_s=125.0)
|
||
assert dataclasses.replace(base, breaker=at_floor).breaker.probe_ttl_s == 125.0
|
||
|
||
# —— sources 非空 ——
|
||
|
||
def test_empty_sources_rejected_with_actionable_message(self):
|
||
"""零源装出来的 client 选源必然失败;消息须点明原因,不能泄漏 max() 的内置异常。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError) as exc:
|
||
dataclasses.replace(base, sources=())
|
||
assert "至少一个源" in str(exc.value)
|
||
assert "empty sequence" not in str(exc.value)
|
||
|
||
# —— 装配路径覆盖 ——
|
||
|
||
def test_factory_cannot_receive_invalid_settings(self):
|
||
"""from_settings 这条路吃不到非法配置。
|
||
|
||
异常实际抛在实参求值(构造 settings)那一刻,而不是工厂内部——这正是
|
||
把守卫放构造期换来的性质: 非法实例根本不存在,无需每个工厂各自设防。
|
||
"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||
|
||
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
|
||
|
||
# —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——
|
||
|
||
@pytest.mark.parametrize(
|
||
("field", "bad_value"),
|
||
[
|
||
("limiter_backend", "rediss"),
|
||
("breaker_backend", "sqlite"),
|
||
("cache_backend", "postgres"),
|
||
("telemetry_backend", "redis"),
|
||
("selector", "random"),
|
||
("quota_full", "block"),
|
||
],
|
||
)
|
||
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
|
||
"""域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match=field):
|
||
dataclasses.replace(base, **{field: bad_value})
|
||
|
||
# —— 条件必填: 取 redis 的后端必须有 redis_url ——
|
||
|
||
@pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
|
||
def test_redis_backend_requires_redis_url(self, field):
|
||
"""client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
|
||
base = self._base() # redis_url=None
|
||
with pytest.raises(ValueError, match="redis_url"):
|
||
dataclasses.replace(base, **{field: "redis"})
|
||
|
||
def test_redis_cache_requires_redis_url(self):
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="redis_url"):
|
||
dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)
|
||
|
||
# —— 条件必填: 启用缓存必须有命名空间与正 TTL ——
|
||
|
||
def test_cache_requires_namespace(self):
|
||
"""缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="cache_namespace"):
|
||
dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)
|
||
|
||
def test_cache_ttl_must_be_positive(self):
|
||
"""from_env 明令禁止的"永不过期"不能从另一条路进来。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="cache_ttl_s"):
|
||
dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)
|
||
|
||
# —— 条件必填: 遥测后端各自的落点 ——
|
||
|
||
def test_sqlite_telemetry_requires_path(self):
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="telemetry_sqlite_path"):
|
||
dataclasses.replace(base, telemetry_backend="sqlite")
|
||
|
||
def test_postgres_telemetry_requires_dsn(self):
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
|
||
dataclasses.replace(base, telemetry_backend="postgres")
|
||
|
||
# —— 标量域 ——
|
||
|
||
def test_negative_structured_retries_rejected(self):
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="structured_max_retries"):
|
||
dataclasses.replace(base, structured_max_retries=-1)
|
||
|
||
def test_blank_scope_rejected(self):
|
||
"""空 scope 会污染遥测与缓存命名空间。"""
|
||
base = self._base()
|
||
with pytest.raises(ValueError, match="scope"):
|
||
dataclasses.replace(base, scope=" ")
|
||
|
||
# —— 合法组合仍可构造(收紧的是错的那些)——
|
||
|
||
def test_full_redis_stack_constructible(self):
|
||
base = self._base()
|
||
settings = dataclasses.replace(
|
||
base,
|
||
limiter_backend="redis",
|
||
breaker_backend="redis",
|
||
cache_backend="redis",
|
||
cache_namespace="ns",
|
||
cache_ttl_s=60,
|
||
redis_url="redis://127.0.0.1:6379/3",
|
||
)
|
||
assert settings.cache_ttl_s == 60 and settings.redis_url is not None
|
||
|
||
# —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——
|
||
|
||
def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
|
||
"""asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
|
||
base = self._base()
|
||
with _captured_warnings() as warnings:
|
||
settings = dataclasses.replace(
|
||
base,
|
||
telemetry_backend="postgres",
|
||
telemetry_pg_dsn="postgresql+asyncpg://u@h/db",
|
||
)
|
||
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
|
||
assert any("asyncpg" in m for m in warnings)
|
||
|
||
def test_env_path_strips_dsn_without_warning(self):
|
||
"""env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
|
||
with _captured_warnings() as warnings:
|
||
settings = GatewaySettings.from_env(
|
||
"LLM",
|
||
env=_env(
|
||
PGW_TELEMETRY_BACKEND="postgres",
|
||
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
|
||
),
|
||
)
|
||
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
|
||
assert not warnings
|
||
|
||
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
|
||
|
||
def test_factory_accepts_valid_redis_stack(self):
|
||
"""补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
|
||
base = self._base()
|
||
settings = dataclasses.replace(
|
||
base,
|
||
limiter_backend="redis",
|
||
breaker_backend="redis",
|
||
redis_url="redis://127.0.0.1:6379/3",
|
||
)
|
||
client = GatewayClient.from_settings(settings)
|
||
assert client is not None
|