初始提交
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from cli.runtime.app import AppRuntimeService
|
||||
from cli.runtime.app.gateway import AppInfrastructureGateway
|
||||
from cli.runtime.app.support import AppSnapshotSupport
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
|
||||
REDIS_PORT = 6379
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> str:
|
||||
"""
|
||||
返回固定 Python 可执行文件。
|
||||
|
||||
:return: Python 可执行文件路径
|
||||
"""
|
||||
return '/usr/bin/python3'
|
||||
|
||||
|
||||
def test_app_snapshot_support_builds_config_snapshot() -> None:
|
||||
"""
|
||||
校验应用快照支持对象会构建应用配置快照。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
|
||||
|
||||
fake_env_module = SimpleNamespace(
|
||||
AppConfig=SimpleNamespace(
|
||||
app_env='dev',
|
||||
app_name='shierfeng',
|
||||
app_host='127.0.0.1',
|
||||
app_port=8080,
|
||||
app_root_path='/api',
|
||||
app_reload=True,
|
||||
app_workers=1,
|
||||
app_disable_swagger=False,
|
||||
app_disable_redoc=False,
|
||||
),
|
||||
DataBaseConfig=SimpleNamespace(
|
||||
db_type='mysql',
|
||||
db_host='127.0.0.1',
|
||||
db_port=3306,
|
||||
db_database='shierfeng',
|
||||
),
|
||||
RedisConfig=SimpleNamespace(redis_host='127.0.0.1', redis_port=REDIS_PORT),
|
||||
LogConfig=SimpleNamespace(loguru_level='INFO'),
|
||||
TransportCryptoConfig=SimpleNamespace(
|
||||
transport_crypto_enabled=True,
|
||||
transport_crypto_mode='strict',
|
||||
),
|
||||
)
|
||||
|
||||
def _fake_get_env_module() -> SimpleNamespace:
|
||||
return fake_env_module
|
||||
|
||||
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
|
||||
|
||||
payload = support.build_app_config_snapshot()
|
||||
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dbType'] == 'mysql'
|
||||
assert payload['redisPort'] == REDIS_PORT
|
||||
assert payload['transportCryptoMode'] == 'strict'
|
||||
|
||||
|
||||
def test_app_snapshot_support_builds_env_snapshot(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验应用快照支持对象会构建环境解析快照。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
|
||||
fake_env_module = SimpleNamespace(
|
||||
AppConfig=SimpleNamespace(app_env='prod'),
|
||||
)
|
||||
|
||||
def _fake_get_env_module() -> SimpleNamespace:
|
||||
return fake_env_module
|
||||
|
||||
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
|
||||
monkeypatch.setenv('APP_ENV', 'test')
|
||||
|
||||
payload = support.build_app_env_snapshot()
|
||||
|
||||
assert payload == {
|
||||
'cliEnv': 'test',
|
||||
'configEnv': 'prod',
|
||||
'appEnv': 'test',
|
||||
'envFile': '.env.test',
|
||||
'envFilePath': '/tmp/shierfeng-backend/.env.test',
|
||||
'envFileExists': False,
|
||||
'backendDir': '/tmp/shierfeng-backend',
|
||||
'pythonExecutable': '/usr/bin/python3',
|
||||
}
|
||||
|
||||
|
||||
def test_app_runtime_service_builds_app_instance() -> None:
|
||||
"""
|
||||
校验应用运行时 facade 会通过基础设施网关构建应用实例。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
service = AppRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
infrastructure_gateway=gateway,
|
||||
)
|
||||
|
||||
class FakeServerModule:
|
||||
"""
|
||||
模拟 server 模块。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_app() -> dict[str, str]:
|
||||
"""
|
||||
返回模拟应用实例。
|
||||
|
||||
:return: 模拟应用实例
|
||||
"""
|
||||
return {'app': 'ok'}
|
||||
|
||||
def _fake_get_server_module() -> FakeServerModule:
|
||||
return FakeServerModule()
|
||||
|
||||
object.__setattr__(gateway, 'get_server_module', _fake_get_server_module)
|
||||
|
||||
payload = service.build_app_instance()
|
||||
|
||||
assert payload == {'app': 'ok'}
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
from cli.core.app_builder import ProjectRuntimeLocator
|
||||
from cli.runtime.base import RUNTIME_ENVIRONMENT
|
||||
from cli.utils import NestedCliProjectLocator
|
||||
|
||||
|
||||
def test_backend_project_dir_detection_uses_consistent_rule(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验不同入口的后端目录判定规则保持一致。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
(tmp_path / 'app.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'config').mkdir()
|
||||
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'cli').mkdir()
|
||||
|
||||
project_runtime_locator = ProjectRuntimeLocator()
|
||||
nested_cli_project_locator = NestedCliProjectLocator()
|
||||
|
||||
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is True
|
||||
assert project_runtime_locator.is_backend_project_dir(tmp_path) is True
|
||||
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is True
|
||||
|
||||
|
||||
def test_backend_project_dir_detection_rejects_directory_without_cli_package(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验缺少 cli 目录时不会被误判为后端项目根目录。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
(tmp_path / 'app.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'config').mkdir()
|
||||
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
|
||||
|
||||
project_runtime_locator = ProjectRuntimeLocator()
|
||||
nested_cli_project_locator = NestedCliProjectLocator()
|
||||
|
||||
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is False
|
||||
assert project_runtime_locator.is_backend_project_dir(tmp_path) is False
|
||||
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is False
|
||||
@@ -0,0 +1,164 @@
|
||||
import pytest
|
||||
|
||||
from cli.runtime.cache import CacheRuntimeService
|
||||
from cli.runtime.cache.gateway import REDIS_TTL_KEY_NOT_FOUND, CacheInfrastructureGateway
|
||||
from cli.runtime.cache.support import CacheDomainSupport
|
||||
|
||||
EXPECTED_MATCHED_KEY_COUNT = 2
|
||||
|
||||
|
||||
def test_cache_domain_support_extracts_scoped_keys() -> None:
|
||||
"""
|
||||
校验缓存领域支持对象会提取指定缓存名前缀下的相对键名。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CacheDomainSupport(CacheInfrastructureGateway())
|
||||
|
||||
key_items = support.extract_cache_key_items(
|
||||
'sys_config',
|
||||
['sys_config:site_name', 'sys_config:logo', 'login_tokens:user:1'],
|
||||
)
|
||||
|
||||
assert key_items == ['logo', 'site_name']
|
||||
|
||||
|
||||
def test_cache_domain_support_builds_missing_cache_result() -> None:
|
||||
"""
|
||||
校验缓存领域支持对象会生成统一的缓存不存在结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CacheDomainSupport(CacheInfrastructureGateway())
|
||||
|
||||
payload = support.build_missing_cache_result('sys_config', 'site_name')
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['fullCacheKey'] == 'sys_config:site_name'
|
||||
assert payload['message'] == '缓存不存在:sys_config:site_name'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_runtime_service_ttl_returns_missing_result() -> None:
|
||||
"""
|
||||
校验缓存运行时在 TTL 返回不存在标记时会输出统一缺失结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def ttl(full_cache_key: str) -> int:
|
||||
"""
|
||||
返回 Redis 键不存在标记。
|
||||
|
||||
:param full_cache_key: 完整缓存键名
|
||||
:return: Redis TTL 特殊值
|
||||
"""
|
||||
assert full_cache_key == 'sys_config:site_name'
|
||||
return REDIS_TTL_KEY_NOT_FOUND
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
关闭客户端。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具类。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
gateway = CacheInfrastructureGateway()
|
||||
service = CacheRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
|
||||
payload = await service.get_cache_ttl('sys_config', 'site_name')
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['fullCacheKey'] == 'sys_config:site_name'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_runtime_service_clear_cache_dry_run_reports_scope() -> None:
|
||||
"""
|
||||
校验缓存运行时 dry-run 清理会返回匹配键与清理范围。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def keys(pattern: str) -> list[str]:
|
||||
"""
|
||||
返回匹配键列表。
|
||||
|
||||
:param pattern: 匹配模式
|
||||
:return: 匹配结果
|
||||
"""
|
||||
assert pattern == 'sys_config:*'
|
||||
return ['sys_config:site_name', 'sys_config:logo']
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
关闭客户端。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具类。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
gateway = CacheInfrastructureGateway()
|
||||
service = CacheRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
|
||||
payload = await service.clear_cache(cache_name='sys_config', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['matchedCount'] == EXPECTED_MATCHED_KEY_COUNT
|
||||
assert payload['scope'] == {'mode': 'cacheName', 'cacheName': 'sys_config'}
|
||||
assert payload['message'] == '缓存清理演练完成,未执行实际删除'
|
||||
@@ -0,0 +1,210 @@
|
||||
import pytest
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
from cli.runtime.config import ConfigRuntimeService
|
||||
from cli.runtime.config.gateway import ConfigInfrastructureGateway
|
||||
from cli.runtime.config.support import ConfigDomainSupport
|
||||
|
||||
MISSING_CONFIG_KEY = 'sys.demo.key'
|
||||
|
||||
|
||||
def test_config_domain_support_builds_missing_config_result() -> None:
|
||||
"""
|
||||
校验参数配置领域支持对象会生成统一的缺失结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = ConfigDomainSupport(ConfigInfrastructureGateway())
|
||||
|
||||
payload = support.build_missing_config_result(MISSING_CONFIG_KEY, 'cache')
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': f'参数缓存不存在:{MISSING_CONFIG_KEY}',
|
||||
'source': 'cache',
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
|
||||
def test_config_domain_support_serializes_cache_payload_with_sanitizer() -> None:
|
||||
"""
|
||||
校验参数配置领域支持对象会对缓存配置做脱敏序列化。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSanitizer:
|
||||
"""
|
||||
模拟脱敏工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def sanitize_data(payload: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
返回带标记的脱敏结果。
|
||||
|
||||
:param payload: 原始载荷
|
||||
:return: 脱敏结果
|
||||
"""
|
||||
return {**payload, 'sanitized': 'yes'}
|
||||
|
||||
gateway = ConfigInfrastructureGateway()
|
||||
support = ConfigDomainSupport(gateway)
|
||||
|
||||
def _fake_get_log_sanitizer() -> FakeSanitizer:
|
||||
return FakeSanitizer()
|
||||
|
||||
object.__setattr__(gateway, 'get_log_sanitizer', _fake_get_log_sanitizer)
|
||||
|
||||
payload = support.serialize_cache_payload(MISSING_CONFIG_KEY, 'demo-value')
|
||||
|
||||
assert payload == {
|
||||
'configKey': MISSING_CONFIG_KEY,
|
||||
'configValue': 'demo-value',
|
||||
'sanitized': 'yes',
|
||||
}
|
||||
|
||||
|
||||
def test_config_runtime_service_builds_cli_config_model_from_orm_record() -> None:
|
||||
"""
|
||||
校验 CLI 会将 ORM 配置记录显式映射为可序列化的配置模型。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeOrmConfigRecord:
|
||||
"""
|
||||
模拟 ORM 配置记录。
|
||||
"""
|
||||
|
||||
config_id = 2
|
||||
config_name = '用户管理-账号初始密码'
|
||||
config_key = 'sys.user.initPassword'
|
||||
config_value = '123456'
|
||||
config_type = 'Y'
|
||||
create_by = 'admin'
|
||||
create_time = None
|
||||
update_by = ''
|
||||
update_time = None
|
||||
remark = '初始化密码 123456'
|
||||
|
||||
class FakeConfigModel:
|
||||
"""
|
||||
模拟 VO 配置模型。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
class FakeConfigVoModule:
|
||||
"""
|
||||
模拟配置 VO 模块。
|
||||
"""
|
||||
|
||||
ConfigModel = FakeConfigModel
|
||||
|
||||
model = ConfigRuntimeService.build_cli_config_model(FakeConfigVoModule, FakeOrmConfigRecord())
|
||||
|
||||
assert isinstance(model, FakeConfigModel)
|
||||
assert model.kwargs == {
|
||||
'configId': 2,
|
||||
'configName': '用户管理-账号初始密码',
|
||||
'configKey': 'sys.user.initPassword',
|
||||
'configValue': '123456',
|
||||
'configType': 'Y',
|
||||
'createBy': 'admin',
|
||||
'createTime': None,
|
||||
'updateBy': '',
|
||||
'updateTime': None,
|
||||
'remark': '初始化密码 123456',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_runtime_service_returns_missing_db_config_result() -> None:
|
||||
"""
|
||||
校验参数配置运行时在数据库中未找到配置时会返回统一结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
模拟异步会话。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeSession':
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""
|
||||
模拟异步会话工厂。
|
||||
"""
|
||||
|
||||
def __call__(self) -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
class FakeConfigModel:
|
||||
"""
|
||||
模拟配置模型。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: str) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
class FakeConfigVoModule:
|
||||
"""
|
||||
模拟配置 VO 模块。
|
||||
"""
|
||||
|
||||
ConfigModel = FakeConfigModel
|
||||
|
||||
class FakeConfigDao:
|
||||
"""
|
||||
模拟配置 DAO。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def get_config_detail_by_info(session: FakeSession, config_model: FakeConfigModel) -> None:
|
||||
"""
|
||||
返回空结果。
|
||||
|
||||
:param session: 数据库会话
|
||||
:param config_model: 配置模型
|
||||
:return: None
|
||||
"""
|
||||
assert isinstance(session, FakeSession)
|
||||
assert config_model.kwargs == {'configKey': MISSING_CONFIG_KEY}
|
||||
|
||||
gateway = ConfigInfrastructureGateway()
|
||||
service = ConfigRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_async_session_local() -> FakeSessionFactory:
|
||||
return FakeSessionFactory()
|
||||
|
||||
def _fake_get_config_dao() -> FakeConfigDao:
|
||||
return FakeConfigDao()
|
||||
|
||||
def _fake_get_config_vo_module() -> FakeConfigVoModule:
|
||||
return FakeConfigVoModule()
|
||||
|
||||
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
|
||||
object.__setattr__(gateway, 'get_config_dao', _fake_get_config_dao)
|
||||
object.__setattr__(gateway, 'get_config_vo_module', _fake_get_config_vo_module)
|
||||
|
||||
payload = await service.get_config(MISSING_CONFIG_KEY, source='db')
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': f'参数配置不存在:{MISSING_CONFIG_KEY}',
|
||||
'source': 'db',
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR
|
||||
from cli.runtime.crypto import CryptoRuntimeService
|
||||
from cli.runtime.crypto.gateway import CryptoInfrastructureGateway
|
||||
from cli.runtime.crypto.support import CryptoDomainSupport
|
||||
|
||||
INVALID_RSA_KEY_SIZE = 1024
|
||||
VALID_RSA_KEY_SIZE = 2048
|
||||
|
||||
|
||||
def test_crypto_domain_support_rejects_invalid_rsa_key_size() -> None:
|
||||
"""
|
||||
校验传输加密领域支持对象会拒绝非法 RSA 密钥长度。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CryptoDomainSupport(CryptoInfrastructureGateway())
|
||||
|
||||
try:
|
||||
support.validate_rsa_key_size(INVALID_RSA_KEY_SIZE)
|
||||
except ValueError as exc:
|
||||
assert 'RSA 密钥长度必须大于等于 2048' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid RSA key size')
|
||||
|
||||
|
||||
def test_crypto_domain_support_rejects_non_list_legacy_key_pairs() -> None:
|
||||
"""
|
||||
校验传输加密领域支持对象会拒绝非数组格式的历史密钥配置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
support = CryptoDomainSupport(gateway)
|
||||
|
||||
def _fake_transport_crypto_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(transport_crypto_legacy_key_pairs='{"kid": "legacy"}')
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_crypto_config', _fake_transport_crypto_config)
|
||||
|
||||
try:
|
||||
support.load_existing_legacy_key_pairs()
|
||||
except ValueError as exc:
|
||||
assert 'TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid legacy key pairs payload')
|
||||
|
||||
|
||||
def test_crypto_runtime_service_keygen_returns_argument_error_for_invalid_size() -> None:
|
||||
"""
|
||||
校验传输加密运行时在非法密钥长度下返回参数错误结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = CryptoRuntimeService()
|
||||
|
||||
payload = service.generate_crypto_key_pair('kid-20260511', INVALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '生成传输加密密钥失败'
|
||||
|
||||
|
||||
def test_crypto_runtime_service_rotation_rejects_same_kid() -> None:
|
||||
"""
|
||||
校验传输加密运行时会拒绝与当前版本相同的新密钥版本。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
service = CryptoRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
provider = SimpleNamespace(
|
||||
validate_runtime_configuration=lambda: None,
|
||||
get_current_key_pair=lambda: SimpleNamespace(
|
||||
kid='kid-current',
|
||||
private_key_pem='PRIVATE-CURRENT',
|
||||
public_key_pem='PUBLIC-CURRENT',
|
||||
),
|
||||
)
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
|
||||
|
||||
payload = service.build_rotation_payload('kid-current', VALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '生成密钥轮换方案失败'
|
||||
assert payload['error'] == '新密钥版本不能与当前版本相同'
|
||||
|
||||
|
||||
def test_crypto_runtime_service_rotation_builds_merged_payload() -> None:
|
||||
"""
|
||||
校验传输加密运行时会生成包含历史密钥合并结果的轮换辅助数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
domain_support = CryptoDomainSupport(gateway)
|
||||
service = CryptoRuntimeService(
|
||||
infrastructure_gateway=gateway,
|
||||
domain_support=domain_support,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
validate_runtime_configuration=lambda: None,
|
||||
get_current_key_pair=lambda: SimpleNamespace(
|
||||
kid='kid-current',
|
||||
private_key_pem='PRIVATE-CURRENT',
|
||||
public_key_pem='PUBLIC-CURRENT',
|
||||
),
|
||||
)
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
|
||||
object.__setattr__(
|
||||
domain_support,
|
||||
'generate_rsa_key_pair',
|
||||
lambda key_size: ('PRIVATE-NEXT', 'PUBLIC-NEXT'),
|
||||
)
|
||||
object.__setattr__(
|
||||
domain_support,
|
||||
'load_existing_legacy_key_pairs',
|
||||
lambda: [
|
||||
{'kid': 'kid-legacy', 'privateKey': 'PRIVATE-LEGACY', 'publicKey': 'PUBLIC-LEGACY'},
|
||||
{'kid': 'kid-next', 'privateKey': 'STALE-PRIVATE', 'publicKey': 'STALE-PUBLIC'},
|
||||
],
|
||||
)
|
||||
|
||||
payload = service.build_rotation_payload('kid-next', VALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['currentKid'] == 'kid-current'
|
||||
assert payload['nextKid'] == 'kid-next'
|
||||
assert payload['nextKeyPair']['privateKey'] == 'PRIVATE-NEXT'
|
||||
assert payload['nextKeyPair']['publicKey'] == 'PUBLIC-NEXT'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_KID'] == 'kid-next'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_PRIVATE_KEY'] == 'PRIVATE-NEXT'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_PUBLIC_KEY'] == 'PUBLIC-NEXT'
|
||||
|
||||
legacy_key_pairs = payload['legacyKeyPairs']
|
||||
legacy_by_kid = {item['kid']: item for item in legacy_key_pairs}
|
||||
assert 'kid-next' not in legacy_by_kid
|
||||
assert legacy_by_kid['kid-current']['privateKey'] == 'PRIVATE-CURRENT'
|
||||
assert legacy_by_kid['kid-legacy']['publicKey'] == 'PUBLIC-LEGACY'
|
||||
|
||||
serialized_legacy = json.loads(payload['envPatch']['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS'])
|
||||
serialized_by_kid = {item['kid']: item for item in serialized_legacy}
|
||||
assert serialized_by_kid['kid-current']['publicKey'] == 'PUBLIC-CURRENT'
|
||||
@@ -0,0 +1,144 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
from cli.runtime.db import DatabaseRuntimeService
|
||||
from cli.runtime.db.gateway import DatabaseInfrastructureGateway
|
||||
from cli.runtime.db.support import DatabaseRevisionSupport
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
|
||||
def test_database_revision_support_serializes_revision() -> None:
|
||||
"""
|
||||
校验数据库迁移版本支持对象会序列化 Alembic revision。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRevision:
|
||||
"""
|
||||
模拟 Alembic revision 对象。
|
||||
"""
|
||||
|
||||
revision = '202605110001'
|
||||
down_revision = ('202605100001',)
|
||||
branch_labels = {'main'}
|
||||
dependencies = None
|
||||
doc = ' demo revision '
|
||||
path = Path('/tmp/revision.py')
|
||||
|
||||
support = DatabaseRevisionSupport(DatabaseInfrastructureGateway(), FakeRuntimeEnvironment())
|
||||
|
||||
payload = support.serialize_revision(FakeRevision())
|
||||
|
||||
assert payload == {
|
||||
'revision': '202605110001',
|
||||
'downRevisions': ['202605100001'],
|
||||
'branchLabels': ['main'],
|
||||
'dependsOn': [],
|
||||
'doc': 'demo revision',
|
||||
'path': '/tmp/revision.py',
|
||||
}
|
||||
|
||||
|
||||
def test_database_runtime_service_upgrade_dry_run_returns_command_payload() -> None:
|
||||
"""
|
||||
校验数据库运行时 dry-run 升级会返回命令预览结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = DatabaseRuntimeService(runtime_environment=FakeRuntimeEnvironment())
|
||||
|
||||
payload = service.upgrade_database('head', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['cwd'] == '/tmp/shierfeng-backend'
|
||||
assert payload['command'] == ['alembic', '-c', '/tmp/shierfeng-backend/alembic.ini', 'upgrade', 'head']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_runtime_service_ping_database_returns_failure() -> None:
|
||||
"""
|
||||
校验数据库运行时在连接异常时会返回失败结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeConnection:
|
||||
"""
|
||||
模拟数据库连接对象。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeConnection':
|
||||
raise RuntimeError('db boom')
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeEngine:
|
||||
"""
|
||||
模拟数据库引擎。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def connect() -> FakeConnection:
|
||||
"""
|
||||
返回模拟连接对象。
|
||||
|
||||
:return: 模拟连接对象
|
||||
"""
|
||||
return FakeConnection()
|
||||
|
||||
@staticmethod
|
||||
async def dispose() -> None:
|
||||
"""
|
||||
释放引擎资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
gateway = DatabaseInfrastructureGateway()
|
||||
service = DatabaseRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
infrastructure_gateway=gateway,
|
||||
)
|
||||
|
||||
def _fake_get_async_db_engine_factory() -> object:
|
||||
def _factory(*, echo: bool = False) -> FakeEngine:
|
||||
del echo
|
||||
return FakeEngine()
|
||||
|
||||
return _factory
|
||||
|
||||
def _fake_get_sqlalchemy_text() -> object:
|
||||
return lambda sql: sql
|
||||
|
||||
object.__setattr__(gateway, 'get_async_db_engine_factory', _fake_get_async_db_engine_factory)
|
||||
object.__setattr__(gateway, 'get_sqlalchemy_text', _fake_get_sqlalchemy_text)
|
||||
|
||||
payload = await service.ping_database()
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '数据库连接失败'
|
||||
assert 'db boom' in payload['error']
|
||||
@@ -0,0 +1,126 @@
|
||||
from cli.exit_codes import DEPENDENCY_ERROR
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
from cli.runtime.dev import DevelopmentRuntimeService
|
||||
from cli.runtime.dev.gateway import DevelopmentProcessGateway
|
||||
from cli.runtime.dev.support import DevelopmentCommandSupport, DevelopmentToolingSupport
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> str:
|
||||
"""
|
||||
返回固定 Python 可执行文件。
|
||||
|
||||
:return: Python 可执行文件路径
|
||||
"""
|
||||
return '/usr/bin/python3'
|
||||
|
||||
|
||||
def test_development_runtime_service_returns_dependency_error_when_pytest_missing() -> None:
|
||||
"""
|
||||
校验开发运行时在 pytest 缺失时会返回依赖错误结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeToolingSupport(DevelopmentToolingSupport):
|
||||
"""
|
||||
模拟开发工具支持对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_pytest_available() -> bool:
|
||||
"""
|
||||
返回 pytest 不可用状态。
|
||||
|
||||
:return: False
|
||||
"""
|
||||
return False
|
||||
|
||||
service = DevelopmentRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
tooling_support=FakeToolingSupport(),
|
||||
)
|
||||
|
||||
payload = service.run_tests(['tests/cli'])
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': 'pytest 未安装,无法执行 dev test',
|
||||
'hint': '请在当前环境安装 pytest 后重试',
|
||||
'exit_code': DEPENDENCY_ERROR,
|
||||
}
|
||||
|
||||
|
||||
def test_development_runtime_service_run_lint_uses_command_support() -> None:
|
||||
"""
|
||||
校验开发运行时会通过命令支持对象串联 format/check 执行流程。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeCommandSupport(DevelopmentCommandSupport):
|
||||
"""
|
||||
模拟开发命令支持对象。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recorded_commands: list[list[str]] = []
|
||||
|
||||
def build_format_command(self, normalized_targets: list[str], *, check_only: bool) -> list[str]:
|
||||
assert normalized_targets == ['tests/cli']
|
||||
assert check_only is True
|
||||
return ['format-command']
|
||||
|
||||
def build_check_command(
|
||||
self,
|
||||
normalized_targets: list[str],
|
||||
*,
|
||||
check_only: bool,
|
||||
fix: bool,
|
||||
unsafe_fixes: bool,
|
||||
) -> list[str]:
|
||||
assert normalized_targets == ['tests/cli']
|
||||
assert check_only is True
|
||||
assert fix is False
|
||||
assert unsafe_fixes is False
|
||||
return ['check-command']
|
||||
|
||||
class FakeProcessGateway(DevelopmentProcessGateway):
|
||||
"""
|
||||
模拟开发子进程执行网关。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recorded_commands: list[list[str]] = []
|
||||
|
||||
def run_command(self, command: list[str]) -> dict[str, object]:
|
||||
self.recorded_commands.append(command)
|
||||
return {'ok': True, 'command': command, 'returnCode': 0}
|
||||
|
||||
command_support = FakeCommandSupport()
|
||||
process_gateway = FakeProcessGateway()
|
||||
service = DevelopmentRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
command_support=command_support,
|
||||
process_gateway=process_gateway,
|
||||
)
|
||||
|
||||
payload = service.run_lint(['tests/cli'], check_only=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['targets'] == ['tests/cli']
|
||||
assert process_gateway.recorded_commands == [['format-command'], ['check-command']]
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR
|
||||
from cli.runtime.gen import GenRuntimeService
|
||||
from cli.runtime.gen.gateway import GenInfrastructureGateway
|
||||
from cli.runtime.gen.support import GenDomainSupport
|
||||
|
||||
REDIS_UNUSED = 'unused'
|
||||
|
||||
|
||||
def test_gen_domain_support_resolves_sql_text_from_argument() -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会优先使用直接传入的 SQL 文本。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
|
||||
payload = support.resolve_sql_text(' CREATE TABLE demo(id bigint); ', '')
|
||||
|
||||
assert payload == 'CREATE TABLE demo(id bigint);'
|
||||
|
||||
|
||||
def test_gen_domain_support_resolve_sql_text_rejects_invalid_inputs(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会拒绝无效 SQL 输入组合。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
sql_file = tmp_path / 'demo.sql'
|
||||
sql_file.write_text('select 1', encoding='utf-8')
|
||||
|
||||
try:
|
||||
support.resolve_sql_text('select 1', str(sql_file))
|
||||
except ValueError as exc:
|
||||
assert '必须且只能传入 --sql 或 --sql-file 其中一种方式' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for conflicting SQL inputs')
|
||||
|
||||
|
||||
def test_gen_domain_support_writes_export_zip(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会写出导出压缩包。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
target_file = tmp_path / 'exports' / 'gen.zip'
|
||||
|
||||
output_path = support.write_export_zip(str(target_file), b'zip-content')
|
||||
|
||||
assert output_path == str(target_file.resolve())
|
||||
assert target_file.read_bytes() == b'zip-content'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gen_runtime_service_export_code_dry_run_returns_preview_payload() -> None:
|
||||
"""
|
||||
校验代码生成运行时在 dry-run 导出时会返回结构化预览结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = GenInfrastructureGateway()
|
||||
service = GenRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_gen_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(allow_overwrite=True, GEN_PATH='/tmp/gen')
|
||||
|
||||
object.__setattr__(gateway, 'get_gen_config', _fake_get_gen_config)
|
||||
|
||||
payload = await service.export_code(['sys_user', 'sys_role'], mode='zip', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['tableNames'] == ['sys_user', 'sys_role']
|
||||
assert payload['outputFile'].endswith('gen_code_sys_user_sys_role.zip')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gen_runtime_service_import_tables_requires_at_least_one_table() -> None:
|
||||
"""
|
||||
校验代码生成运行时在未传入表名时会返回参数错误。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = GenRuntimeService()
|
||||
|
||||
payload = await service.import_tables([])
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '至少需要传入一个表名'
|
||||
@@ -0,0 +1,174 @@
|
||||
import pytest
|
||||
|
||||
from cli.runtime.job import JobRuntimeService
|
||||
from cli.runtime.job.gateway import JobInfrastructureGateway
|
||||
from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport
|
||||
|
||||
MISSING_JOB_ID = 99
|
||||
|
||||
|
||||
def test_job_domain_support_builds_operation_metadata() -> None:
|
||||
"""
|
||||
校验任务领域支持对象会返回统一的任务操作元数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = JobDomainSupport(JobInfrastructureGateway())
|
||||
|
||||
payload = support.build_job_operation_metadata('pause')
|
||||
|
||||
assert payload == {
|
||||
'operationLabel': '暂停任务',
|
||||
'successMessage': '定时任务已暂停',
|
||||
}
|
||||
|
||||
|
||||
def test_job_domain_support_serializes_model_dump_items() -> None:
|
||||
"""
|
||||
校验任务领域支持对象会序列化带 `model_dump` 的任务项。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeJobModel:
|
||||
"""
|
||||
模拟任务模型。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def model_dump(*, by_alias: bool = False, exclude_none: bool = False) -> dict[str, object]:
|
||||
"""
|
||||
返回模拟序列化结果。
|
||||
|
||||
:param by_alias: 是否按别名输出
|
||||
:param exclude_none: 是否排除空值
|
||||
:return: 模拟序列化结果
|
||||
"""
|
||||
assert by_alias is True
|
||||
assert exclude_none is True
|
||||
return {'jobId': 1, 'jobName': 'sync-job'}
|
||||
|
||||
support = JobDomainSupport(JobInfrastructureGateway())
|
||||
|
||||
payload = support.serialize_job_item(FakeJobModel())
|
||||
|
||||
assert payload == {'jobId': 1, 'jobName': 'sync-job'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_scheduler_support_closes_scheduler_and_redis() -> None:
|
||||
"""
|
||||
校验任务调度支持对象会关闭调度器和 Redis 资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events: list[str] = []
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
记录 Redis 关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('redis')
|
||||
|
||||
class FakeSchedulerUtil:
|
||||
"""
|
||||
模拟调度器工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close_system_scheduler() -> None:
|
||||
"""
|
||||
记录调度器关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('scheduler')
|
||||
|
||||
gateway = JobInfrastructureGateway()
|
||||
support = JobSchedulerSupport(gateway)
|
||||
|
||||
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
|
||||
return FakeSchedulerUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
|
||||
|
||||
await support.close_scheduler_context(FakeRedis())
|
||||
|
||||
assert close_events == ['scheduler', 'redis']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_runtime_service_reports_missing_job_detail() -> None:
|
||||
"""
|
||||
校验任务运行时在任务详情不存在时会返回统一结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
模拟异步会话。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeSession':
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""
|
||||
模拟异步会话工厂。
|
||||
"""
|
||||
|
||||
def __call__(self) -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
class FakeJobService:
|
||||
"""
|
||||
模拟任务服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def job_detail_services(session: FakeSession, job_id: int) -> dict[str, object]:
|
||||
"""
|
||||
返回不存在任务的空结果。
|
||||
|
||||
:param session: 数据库会话
|
||||
:param job_id: 任务 ID
|
||||
:return: 空结果
|
||||
"""
|
||||
assert isinstance(session, FakeSession)
|
||||
assert job_id == MISSING_JOB_ID
|
||||
return {}
|
||||
|
||||
gateway = JobInfrastructureGateway()
|
||||
service = JobRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_async_session_local() -> FakeSessionFactory:
|
||||
return FakeSessionFactory()
|
||||
|
||||
def _fake_get_job_service() -> FakeJobService:
|
||||
return FakeJobService()
|
||||
|
||||
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
|
||||
object.__setattr__(gateway, 'get_job_service', _fake_get_job_service)
|
||||
|
||||
payload = await service.get_job_detail(MISSING_JOB_ID)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['jobId'] == MISSING_JOB_ID
|
||||
assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}'
|
||||
@@ -0,0 +1,167 @@
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.runtime.ops import OperationsRuntimeService
|
||||
from cli.runtime.ops.gateway import OperationsInfrastructureGateway
|
||||
from cli.runtime.ops.support import OperationsDependencyInspector, OperationsServerInfoSupport
|
||||
|
||||
|
||||
def test_operations_dependency_inspector_reports_missing_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
校验运维依赖检查器会报告缺失的必需依赖。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
inspector = OperationsDependencyInspector()
|
||||
monkeypatch.setattr(
|
||||
inspector,
|
||||
'read_package_version',
|
||||
lambda distribution_name: None if distribution_name == 'fastapi' else '1.0.0',
|
||||
)
|
||||
|
||||
payload = inspector.inspect()
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['missingRequired'] == ['fastapi']
|
||||
assert payload['packages']['python']['installed'] is True
|
||||
|
||||
|
||||
def test_operations_server_info_support_resolves_non_loopback_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
校验服务器信息支持对象会在主机名解析失败时回退到网卡地址。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakePsutil:
|
||||
"""
|
||||
模拟 psutil 模块。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def net_if_addrs() -> dict[str, list[SimpleNamespace]]:
|
||||
"""
|
||||
返回模拟网卡地址。
|
||||
|
||||
:return: 模拟网卡地址映射
|
||||
"""
|
||||
return {
|
||||
'en0': [
|
||||
SimpleNamespace(family=socket.AF_INET, address='127.0.0.1'),
|
||||
SimpleNamespace(family=socket.AF_INET, address='192.168.1.8'),
|
||||
]
|
||||
}
|
||||
|
||||
gateway = OperationsInfrastructureGateway()
|
||||
support = OperationsServerInfoSupport(gateway)
|
||||
|
||||
def _fake_get_psutil_module() -> FakePsutil:
|
||||
return FakePsutil()
|
||||
|
||||
def _fake_gethostbyname(hostname: str) -> str:
|
||||
del hostname
|
||||
raise OSError('boom')
|
||||
|
||||
monkeypatch.setattr(gateway, 'get_psutil_module', _fake_get_psutil_module)
|
||||
monkeypatch.setattr(socket, 'gethostbyname', _fake_gethostbyname)
|
||||
|
||||
resolved_ip = support.resolve_server_ip('demo-host')
|
||||
|
||||
assert resolved_ip == '192.168.1.8'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operations_runtime_service_sync_jobs_closes_resources_on_success() -> None:
|
||||
"""
|
||||
校验运维运行时在同步调度成功后会关闭调度器和 Redis 资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events: list[str] = []
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
记录关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('redis')
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
class FakeSchedulerUtil:
|
||||
"""
|
||||
模拟调度器工具。
|
||||
"""
|
||||
|
||||
_is_leader = True
|
||||
|
||||
@staticmethod
|
||||
async def init_system_scheduler(redis: FakeRedis) -> None:
|
||||
"""
|
||||
初始化调度器。
|
||||
|
||||
:param redis: Redis 客户端
|
||||
:return: None
|
||||
"""
|
||||
assert isinstance(redis, FakeRedis)
|
||||
|
||||
@staticmethod
|
||||
async def request_scheduler_sync() -> None:
|
||||
"""
|
||||
发送调度同步请求。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close_system_scheduler() -> None:
|
||||
"""
|
||||
记录调度器关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('scheduler')
|
||||
|
||||
gateway = OperationsInfrastructureGateway()
|
||||
service = OperationsRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
|
||||
return FakeSchedulerUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
|
||||
|
||||
payload = await service.sync_jobs()
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['schedulerSyncRequested'] is True
|
||||
assert payload['isLeader'] is True
|
||||
assert close_events == ['scheduler', 'redis']
|
||||
Reference in New Issue
Block a user