Files
sap-cli-skill/tests/unit/test_app_config.py
T
吴让宇 c5905a5b1e refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

迁移(来自 sap-cli,共 104 文件):
- tests/           692 例测试(15 个文件的内联 sys.path 改指 assets/)
- openspec/        SDD 规格与归档变更(42 文件)
- docs/            开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md)
- .claude/         rules 副本 + settings.json(供 Claude Code)
- .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md
- scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁)

修复(迁移暴露的真实缺陷):
- assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`,
  该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。
  实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令
- pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本
- pyproject urls 改指 sap-cli-skill

机制调整:
- .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行,
  本仓在 Gitee 不会自动跑
- pre-commit 增本地测试门禁(Gitee 上真正生效的那道)
- .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志)
- 大文件上限 100KB→1MB(架构图 512KB)

守卫测试 tests/unit/test_repo_guards.py(10 → 18 例):
- SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5
- references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移)
- VERSION == sapcli.__version__ == README 版本
- 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮)

698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。
docs/RELEASING.md 重写为单源开发流程。
2026-09-11 00:40:15 +08:00

328 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""app.py main() 和 config_cmd.py 单元测试。
运行: python tests/unit/test_app_config.py
"""
from __future__ import annotations
import io
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch, call
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets"))
from sapcli.cli.app import main
from sapcli.commands.config_cmd import cmd_config
from sapcli.exceptions import ConfigError, SapCliError
# ──────────────────────────────────────────────────────────────────────
# 辅助:构造一个假的 SAPConfig
# ──────────────────────────────────────────────────────────────────────
def _fake_config():
"""返回一个 (SAPConfig, loaded_from) 元组。"""
from sapcli.config import SAPConfig
cfg = SAPConfig(host="sap.example.com", client="100", user="TESTUSER", password="secret123")
return cfg, "/tmp/fake_config.ini"
# ──────────────────────────────────────────────────────────────────────
# app.py main() 测试
# ──────────────────────────────────────────────────────────────────────
class TestMainNoCommand(unittest.TestCase):
"""无子命令时 print_help + exit(1)。"""
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_no_command(self, mock_enc, mock_log, mock_ssl):
with patch("sys.argv", ["sap-cli"]):
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 1)
class TestMainConfigShow(unittest.TestCase):
"""config 命令 → cmd_config 被调用。"""
@patch("sapcli.cli.app.cmd_config")
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_config_show(self, mock_enc, mock_log, mock_ssl, mock_cmd_config):
with patch("sys.argv", ["sap-cli", "config", "show"]):
main()
mock_cmd_config.assert_called_once()
# 第一个位置参数是 args (Namespace)
args_passed = mock_cmd_config.call_args[0][0]
self.assertEqual(args_passed.command, "config")
class TestMainAuthLogin(unittest.TestCase):
"""auth login → cmd_auth_login 被调用。"""
@patch("sapcli.cli.app.cmd_auth_login")
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_auth_login(self, mock_enc, mock_log, mock_ssl, mock_login):
with patch("sys.argv", ["sap-cli", "auth", "login"]):
main()
mock_login.assert_called_once()
class TestMainAuthLogout(unittest.TestCase):
"""auth logout → cmd_auth_logout 被调用。"""
@patch("sapcli.cli.app.cmd_auth_logout")
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_auth_logout(self, mock_enc, mock_log, mock_ssl, mock_logout):
with patch("sys.argv", ["sap-cli", "auth", "logout"]):
main()
mock_logout.assert_called_once()
class TestMainAuthStatus(unittest.TestCase):
"""auth status (无子命令) → cmd_auth_status 被调用。"""
@patch("sapcli.cli.app.cmd_auth_status")
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_auth_status(self, mock_enc, mock_log, mock_ssl, mock_status):
with patch("sys.argv", ["sap-cli", "auth", "status"]):
main()
mock_status.assert_called_once()
class TestMainConfigError(unittest.TestCase):
"""load_config 抛 ConfigError → exit(1)。"""
@patch("sapcli.cli.app.load_config", side_effect=ConfigError("配置缺失"))
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_config_error(self, mock_enc, mock_log, mock_ssl, mock_load):
with patch("sys.argv", ["sap-cli", "download", "--name", "ZT", "--type", "report", "--path", "./out"]):
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 1)
class TestMainDownloadSuccess(unittest.TestCase):
"""download 命令全链路 mockload_config → ADTClient → login → cmd_download。"""
@patch("sapcli.cli.app.cmd_download")
@patch("sapcli.cli.app.ADTClient")
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_download_success(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
mock_client = MagicMock()
mock_adt_cls.return_value = mock_client
with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]):
main()
# 验证 ADTClient 被正确构造
mock_adt_cls.assert_called_once_with(
"sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=False
)
# 验证 login 被调用
mock_client.login.assert_called_once()
# 验证 cmd_download 被调用
mock_download.assert_called_once()
# cmd_download 的第二个参数应该是 mock_client
self.assertIs(mock_download.call_args[0][1], mock_client)
class TestMainSapCliError(unittest.TestCase):
"""handler 抛 SapCliError → exit(1)。"""
@patch("sapcli.cli.app.cmd_download", side_effect=SapCliError("下载失败"))
@patch("sapcli.cli.app.ADTClient")
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_sap_cli_error(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
mock_adt_cls.return_value = MagicMock()
with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]):
with self.assertRaises(SystemExit) as ctx:
main()
self.assertEqual(ctx.exception.code, 1)
class TestMainVerifySslFlag(unittest.TestCase):
"""--verify-ssl 参数传递给 ADTClient。"""
@patch("sapcli.cli.app.cmd_download")
@patch("sapcli.cli.app.ADTClient")
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_verify_ssl_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
mock_adt_cls.return_value = MagicMock()
with patch("sys.argv", [
"sap-cli", "--verify-ssl",
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
]):
main()
# verify_ssl 应为 True
mock_adt_cls.assert_called_once_with(
"sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=True
)
class TestMainProfileFlag(unittest.TestCase):
"""--profile 参数传递给 load_config。"""
@patch("sapcli.cli.app.cmd_download")
@patch("sapcli.cli.app.ADTClient")
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
@patch("sapcli.cli.app._suppress_ssl_warnings")
@patch("sapcli.cli.app._setup_logging")
@patch("sapcli.cli.app._setup_encoding")
def test_main_profile_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
mock_adt_cls.return_value = MagicMock()
with patch("sys.argv", [
"sap-cli", "--profile", "DEV",
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
]):
main()
# load_config 应收到 profile="DEV"
mock_load.assert_called_once()
# load_config(config_path, profile=...)
# parser 中 --config 的 dest 是 "config"--profile 的 dest 是 "profile"
# load_config 调用签名: load_config(args.config, profile=getattr(args, "profile", None))
call_kwargs = mock_load.call_args
self.assertEqual(call_kwargs[0][0], None) # args.config = None
# 第二个参数是 profile
# load_config 被调用为 load_config(args.config, profile=args.profile)
# 所以要看 kwargs 或位置参数
if call_kwargs[1]:
self.assertEqual(call_kwargs[1].get("profile"), "DEV")
else:
# 可能是位置参数方式传递
self.assertEqual(call_kwargs[0][1], "DEV")
# ──────────────────────────────────────────────────────────────────────
# config_cmd.py 测试
# ──────────────────────────────────────────────────────────────────────
class TestConfigShowDefault(unittest.TestCase):
"""config show → 显示默认配置。"""
# _config_show 内部用 from sapcli.config import load_config,所以 patch 源模块
@patch("sapcli.config.load_config", return_value=_fake_config())
def test_config_show_default(self, mock_load):
import argparse
args = argparse.Namespace(config=None, config_action="show")
# 捕获 stdout
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
cmd_config(args)
output = mock_out.getvalue()
self.assertIn("sap-cli 当前配置", output)
self.assertIn("sap.example.com", output)
self.assertIn("100", output)
self.assertIn("TESTUSER", output)
self.assertIn("***", output)
class TestConfigListProfiles(unittest.TestCase):
"""config list-profiles → 显示多 profile。"""
def test_config_list_profiles(self):
import argparse
import configparser
# 创建一个临时配置文件包含多个 section
with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f:
parser = configparser.ConfigParser()
parser.add_section("SAP")
parser.set("SAP", "host", "sap.example.com")
parser.add_section("DEV")
parser.set("DEV", "host", "dev.sap.example.com")
parser.write(f)
tmp_path = f.name
try:
args = argparse.Namespace(config=tmp_path, config_action="list-profiles")
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
cmd_config(args)
output = mock_out.getvalue()
self.assertIn("sap-cli 可用 profile", output)
self.assertIn("SAP", output)
self.assertIn("DEV", output)
self.assertIn("(默认)", output) # SAP section 是默认的
finally:
os.unlink(tmp_path)
class TestConfigSetWritesFile(unittest.TestCase):
"""config set → 写入配置文件。"""
def test_config_set_writes_file(self):
import argparse
import configparser
with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f:
f.write("")
tmp_path = f.name
try:
args = argparse.Namespace(
config=tmp_path,
config_action="set",
key="host",
value="newhost.example.com",
profile=None,
)
with patch("sys.stdout", new_callable=io.StringIO):
cmd_config(args)
# 验证文件已写入
parser = configparser.ConfigParser()
parser.read(tmp_path, encoding="utf-8")
self.assertTrue(parser.has_section("SAP"))
self.assertEqual(parser.get("SAP", "host"), "newhost.example.com")
finally:
os.unlink(tmp_path)
class TestConfigSetInvalidKey(unittest.TestCase):
"""config set 无效 key → 报错。"""
def test_config_set_invalid_key(self):
import argparse
args = argparse.Namespace(
config=None,
config_action="set",
key="invalid_key",
value="some_value",
profile=None,
)
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
cmd_config(args)
output = mock_out.getvalue()
self.assertIn("不支持的配置项", output)
if __name__ == "__main__":
unittest.main()