test(common.storage): cover async-only registry + factory contract
The drop-sync refactor is now contractually enforced:
* create_storage({"mode": "sync"|"async"|<any>}) raises StorageConfigError
-- the regression we most want to catch is someone "restoring" the
sync shape by re-introducing mode= handling.
* create_storage() preserves caller's dict, returns an AsyncStorageBackend
subclass, and wraps constructor TypeError into a useful StorageConfigError.
* build_storage_config emits no mode key on either local or s3 branch.
* register_backend() refuses to silently overwrite a different class on
the same name (collision guard), and accepts same-class re-registration.
* registry surfaces the built-in local + s3 classes after import; the
conflict-test cleanup pattern avoids leaking global state across tests.
Adds [tool.pytest.ini_options] (asyncio_mode=auto, testpaths=tests) so
uv run --package common pytest common/tests works from the workspace root.
17 new tests, all green. Backend suite still 136 passed.
This commit is contained in:
@@ -31,3 +31,7 @@ default = true
|
||||
dev = [
|
||||
"pytest>=9.1.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""create_storage 工厂测试:覆盖 mode 字段严格拒绝 + 正常路径。"""
|
||||
|
||||
from common.storage.base import AsyncStorageBackend
|
||||
from common.storage.exceptions import StorageConfigError
|
||||
from common.storage.factory import (
|
||||
PURPOSE_BUCKETS,
|
||||
USAGE_TYPE_TO_PURPOSE,
|
||||
build_storage_config,
|
||||
create_storage,
|
||||
)
|
||||
|
||||
|
||||
# ── mode 字段严格拒绝 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_storage_rejects_mode_sync():
|
||||
"""显式 mode='sync' 现在必须拒绝 —— 同步抽象已砍掉。"""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
create_storage({"type": "local", "base_dir": "/tmp", "mode": "sync"})
|
||||
msg = str(exc_info.value)
|
||||
assert "不再接受 'mode' 字段" in msg
|
||||
|
||||
|
||||
def test_create_storage_rejects_mode_async():
|
||||
"""显式 mode='async' 也必须拒绝 —— 只有 async 一条路,不需要再声明。"""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
create_storage({"type": "s3", "bucket": "x", "mode": "async"})
|
||||
msg = str(exc_info.value)
|
||||
assert "不再接受 'mode' 字段" in msg
|
||||
|
||||
|
||||
def test_create_storage_rejects_any_mode_value():
|
||||
"""任何 mode 字段(包含未来可能新增的合法值)都拒绝 —— 简化语义。"""
|
||||
import pytest
|
||||
|
||||
for value in ("async", "sync", "dual", ""):
|
||||
with pytest.raises(StorageConfigError):
|
||||
create_storage({"type": "local", "base_dir": "/tmp", "mode": value})
|
||||
|
||||
|
||||
# ── 正常路径 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_storage_returns_async_subclass():
|
||||
s = create_storage({"type": "local", "base_dir": "/tmp/cc-factory-test"})
|
||||
assert isinstance(s, AsyncStorageBackend)
|
||||
# 确认是真 AsyncStorageBackend,不是同步兼容形态
|
||||
assert type(s).__name__ == "LocalStorageBackend"
|
||||
|
||||
|
||||
def test_create_storage_missing_type_raises():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
create_storage({"base_dir": "/tmp"})
|
||||
assert "缺少 'type' 字段" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_storage_unknown_type_raises():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
create_storage({"type": "nonexistent"})
|
||||
assert "未知的存储后端" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_storage_does_not_mutate_input_dict():
|
||||
"""调用方传入的字典不能被改写。"""
|
||||
cfg = {"type": "local", "base_dir": "/tmp/cc-no-mutate"}
|
||||
cfg_id = id(cfg)
|
||||
snapshot = dict(cfg)
|
||||
create_storage(cfg)
|
||||
assert dict(cfg) == snapshot, "factory should not mutate caller's dict"
|
||||
assert id(cfg) == cfg_id
|
||||
|
||||
|
||||
def test_create_storage_kwargs_mismatch_raises_wrapped_error():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
create_storage({"type": "local", "base_dir": 12345}) # base_dir 必须是 str
|
||||
msg = str(exc_info.value)
|
||||
assert "参数不匹配" in msg
|
||||
|
||||
|
||||
# ── build_storage_config 不再产生 mode 字段 ─────────────────────────
|
||||
|
||||
|
||||
def test_build_storage_config_local_has_no_mode(monkeypatch):
|
||||
"""local 分支的输出 dict 不能含 'mode'。"""
|
||||
from common import config as common_config
|
||||
|
||||
monkeypatch.setattr(common_config.settings, "storage_backend", "local", raising=False)
|
||||
monkeypatch.setattr(common_config.settings, "local_storage_base_dir", "/tmp", raising=False)
|
||||
|
||||
cfg = build_storage_config("workspace")
|
||||
assert "mode" not in cfg, f"local cfg must not have 'mode', got: {cfg}"
|
||||
assert cfg["type"] == "local"
|
||||
assert "base_dir" in cfg
|
||||
|
||||
|
||||
def test_build_storage_config_s3_has_no_mode(monkeypatch):
|
||||
"""s3 分支的输出 dict 也不能含 'mode'。"""
|
||||
from common import config as common_config
|
||||
|
||||
monkeypatch.setattr(common_config.settings, "storage_backend", "s3", raising=False)
|
||||
monkeypatch.setattr(common_config.settings, "s3_workspace_bucket", "wb", raising=False)
|
||||
monkeypatch.setattr(common_config.settings, "s3_endpoint", "http://s3", raising=False)
|
||||
monkeypatch.setattr(common_config.settings, "s3_access_key", "ak", raising=False)
|
||||
monkeypatch.setattr(common_config.settings, "s3_secret_key", "sk", raising=False)
|
||||
|
||||
cfg = build_storage_config("workspace")
|
||||
assert "mode" not in cfg, f"s3 cfg must not have 'mode', got: {cfg}"
|
||||
assert cfg["type"] == "s3"
|
||||
|
||||
|
||||
def test_build_storage_config_unknown_bucket_raises():
|
||||
import pytest
|
||||
|
||||
from common.storage.exceptions import StorageConfigError
|
||||
with pytest.raises(StorageConfigError):
|
||||
build_storage_config("not-a-real-bucket")
|
||||
|
||||
|
||||
def test_purpose_buckets_constant_complete():
|
||||
"""PURPOSE_BUCKETS 必须覆盖 USAGE_TYPE_TO_PURPOSE 中所有 purpose。"""
|
||||
purposes = set(USAGE_TYPE_TO_PURPOSE.values())
|
||||
assert purposes.issubset(set(PURPOSE_BUCKETS)), (
|
||||
f"missing buckets for purposes: {purposes - set(PURPOSE_BUCKETS)}"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""后端注册表测试:覆盖 register / get / 内置 backend 注册。"""
|
||||
|
||||
from common.storage.base import AsyncStorageBackend
|
||||
from common.storage.exceptions import StorageConfigError
|
||||
from common.storage.registry import (
|
||||
get_backend_class,
|
||||
register_backend,
|
||||
registered_backends,
|
||||
)
|
||||
|
||||
|
||||
def test_local_and_s3_are_registered_at_import_time():
|
||||
"""import common.storage 应该触发 local / s3 的注册。"""
|
||||
backend_classes = registered_backends()
|
||||
assert "local" in backend_classes
|
||||
assert "s3" in backend_classes
|
||||
for cls in backend_classes.values():
|
||||
assert issubclass(cls, AsyncStorageBackend)
|
||||
|
||||
|
||||
def test_get_backend_class_returns_async_subclass():
|
||||
cls = get_backend_class("local")
|
||||
assert issubclass(cls, AsyncStorageBackend)
|
||||
|
||||
|
||||
def test_get_backend_class_unknown_raises():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
get_backend_class("does-not-exist")
|
||||
assert "未知的存储后端" in str(exc_info.value)
|
||||
assert "does-not-exist" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_register_backend_idempotent_for_same_class():
|
||||
"""同一个类对象重复注册是 no-op,不抛错(``is`` 比对,避免重复 import 时误冲突)。"""
|
||||
from common.storage.registry import _REGISTRY
|
||||
|
||||
class _SameAgain(AsyncStorageBackend):
|
||||
pass
|
||||
|
||||
_REGISTRY["same-again-test"] = _SameAgain
|
||||
|
||||
# 第二次装饰同一个类对象:当前实现里装饰器返回 cls 并把 _REGISTRY[name] 重新写一遍。
|
||||
# 直接重新调用 register_backend("same-again-test")(_SameAgain) 不抛错即可。
|
||||
fn = register_backend("same-again-test")
|
||||
result = fn(_SameAgain)
|
||||
assert result is _SameAgain
|
||||
assert _REGISTRY["same-again-test"] is _SameAgain
|
||||
|
||||
_REGISTRY.pop("same-again-test", None)
|
||||
|
||||
|
||||
def test_register_backend_conflict_raises():
|
||||
import pytest
|
||||
|
||||
@register_backend("conflict-test")
|
||||
class A(AsyncStorageBackend):
|
||||
pass
|
||||
|
||||
with pytest.raises(StorageConfigError) as exc_info:
|
||||
|
||||
@register_backend("conflict-test")
|
||||
class B(AsyncStorageBackend):
|
||||
pass
|
||||
|
||||
assert "已被注册" in str(exc_info.value)
|
||||
|
||||
# cleanup:避免污染全局 registry 影响其他测试
|
||||
from common.storage.registry import _REGISTRY
|
||||
_REGISTRY.pop("conflict-test", None)
|
||||
Reference in New Issue
Block a user