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:
tao.chen
2026-08-24 10:30:22 +08:00
parent 6050880c98
commit 24f40b5dd7
5 changed files with 209 additions and 0 deletions
+4
View File
@@ -31,3 +31,7 @@ default = true
dev = [
"pytest>=9.1.1",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
View File
+134
View File
@@ -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)}"
)
+71
View File
@@ -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)