初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -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']