"""commands/clone.py 单元测试 — cmd_clone + _make_profile_client。 clone 命令从一个系统下载对象,上传到另一个系统。client 参数被忽略; 源/目标客户端分别按 --from / --to profile 构建。 运行: python tests/unit/test_clone.py """ from __future__ import annotations import argparse import os import sys import unittest from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) def _args(**kwargs): defaults = { "name": "ZTEST", "type": "report", "from_profile": "DEV", "to_profile": "QA", "corr_nr": None, "config": None, "verify_ssl": False, } defaults.update(kwargs) return argparse.Namespace(**defaults) # ═══════════════════════════════════════════ # _make_profile_client # ═══════════════════════════════════════════ class TestMakeProfileClient(unittest.TestCase): """_make_profile_client 按 profile 加载配置并建立已登录的 ADT 客户端。""" @patch("sapcli.commands.clone.ADTClient") @patch("sapcli.commands.clone.load_config") def test_builds_and_logs_in(self, mock_load, mock_client_cls): from sapcli.commands.clone import _make_profile_client sap_cfg = MagicMock() sap_cfg.host = "h" sap_cfg.client = "100" sap_cfg.user = "u" sap_cfg.password = "p" mock_load.return_value = (sap_cfg, "DEV") inner = MagicMock() mock_client_cls.return_value = inner result = _make_profile_client("/path.ini", "DEV", verify_ssl=True) mock_load.assert_called_once_with("/path.ini", profile="DEV") mock_client_cls.assert_called_once_with( "h", "100", "u", "p", verify_ssl=True, ) inner.login.assert_called_once() self.assertIs(result, inner) @patch("sapcli.commands.clone.ADTClient") @patch("sapcli.commands.clone.load_config") def test_default_verify_ssl_false(self, mock_load, mock_client_cls): from sapcli.commands.clone import _make_profile_client mock_load.return_value = (MagicMock(), "QA") mock_client_cls.return_value = MagicMock() _make_profile_client(None, "QA") # 未传 verify_ssl 时默认 False self.assertEqual(mock_client_cls.call_args[1].get("verify_ssl"), False) # ═══════════════════════════════════════════ # cmd_clone # ═══════════════════════════════════════════ class TestCmdClone(unittest.TestCase): """cmd_clone 跨系统克隆对象。""" def test_no_source_type_raises_invalid_name(self): """functiongroup 无源码 → InvalidNameError。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import InvalidNameError with self.assertRaises(InvalidNameError): cmd_clone(_args(type="functiongroup", name="ZFG"), MagicMock()) @patch("sapcli.commands.clone._make_profile_client") def test_source_connection_failure_raises_config_error(self, mock_make): """源系统连接失败 → ConfigError。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import ConfigError mock_make.side_effect = ConnectionError("host unreachable") with patch("builtins.print"): with self.assertRaises(ConfigError) as ctx: cmd_clone(_args(), None) self.assertIn("源系统", str(ctx.exception)) @patch("sapcli.commands.clone._make_profile_client") def test_source_object_not_found_raises(self, mock_make): """源对象不存在 → ObjectNotFoundError,且关闭源 session。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import ObjectNotFoundError src_client = MagicMock() src_client.object_exists.return_value = False mock_make.return_value = src_client with patch("builtins.print"): with self.assertRaises(ObjectNotFoundError): cmd_clone(_args(), None) # 即使失败也关闭源 session(finally 块) src_client.session.close.assert_called_once() @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_success_target_exists_normalizes_source(self, mock_make, mock_sync): """目标对象已存在 → 同步成功;源码 \r\n 归一化为 \n 并写入临时文件。""" from sapcli.commands.clone import cmd_clone src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest.\r\nWRITE 'hi'.\r\n" tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] captured = {} def fake_sync(name, obj_type, path, client, corr_nr=None): with open(path, "r", encoding="utf-8") as f: captured["content"] = f.read() captured["client"] = client captured["corr_nr"] = corr_nr return (True, None, corr_nr or "DEVK001") mock_sync.side_effect = fake_sync with patch("builtins.print"): cmd_clone(_args(corr_nr="DEVK901362"), None) # 源码换行归一化 self.assertEqual(captured["content"], "REPORT ztest.\nWRITE 'hi'.\n") # _sync_single 收到的是目标客户端 self.assertIs(captured["client"], tgt_client) self.assertEqual(captured["corr_nr"], "DEVK901362") # 两个 session 都关闭 src_client.session.close.assert_called_once() tgt_client.session.close.assert_called_once() @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_success_target_not_exists(self, mock_make, mock_sync): """目标对象不存在时打印「将创建并同步」并继续。""" from sapcli.commands.clone import cmd_clone src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = False mock_make.side_effect = [src_client, tgt_client] mock_sync.return_value = (True, None, None) with patch("builtins.print") as mock_print: cmd_clone(_args(), None) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("将创建并同步", printed) @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_sync_failure_raises_sapcli_error(self, mock_make, mock_sync): """_sync_single 返回失败 → SapCliError。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import SapCliError src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] mock_sync.return_value = (False, "激活失败: 行3", None) with patch("builtins.print"): with self.assertRaises(SapCliError) as ctx: cmd_clone(_args(), None) self.assertIn("激活失败", str(ctx.exception)) tgt_client.session.close.assert_called_once() @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_sync_failure_with_empty_error_uses_default(self, mock_make, mock_sync): """_sync_single 返回失败但 error_msg 为空 → 使用默认消息。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import SapCliError src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] mock_sync.return_value = (False, "", None) with patch("builtins.print"): with self.assertRaises(SapCliError) as ctx: cmd_clone(_args(name="ZMYCLS", type="class"), None) self.assertIn("ZMYCLS", str(ctx.exception)) @patch("sapcli.commands.clone._make_profile_client") def test_target_connection_failure_raises_config_error(self, mock_make): """目标系统连接失败 → ConfigError(源已成功连接并下载)。""" from sapcli.commands.clone import cmd_clone from sapcli.exceptions import ConfigError src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." mock_make.side_effect = [src_client, ConnectionError("target down")] with patch("builtins.print"): with self.assertRaises(ConfigError) as ctx: cmd_clone(_args(), None) self.assertIn("目标系统", str(ctx.exception)) # 源 session 已关闭 src_client.session.close.assert_called_once() @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_no_corr_nr_omits_transport_line(self, mock_make, mock_sync): """未指定 corr_nr 时不打印「目标传输请求」行。""" from sapcli.commands.clone import cmd_clone src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] mock_sync.return_value = (True, None, None) with patch("builtins.print") as mock_print: cmd_clone(_args(corr_nr=None), None) # 同步成功;actual_corr 为 None 时不打印目标传输请求 printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("克隆完成", printed) @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone._make_profile_client") def test_temp_file_cleaned_up(self, mock_make, mock_sync): """同步成功后临时文件被删除。""" from sapcli.commands.clone import cmd_clone src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] captured = {} def fake_sync(name, obj_type, path, client, corr_nr=None): captured["path"] = path return (True, None, "DEVK001") mock_sync.side_effect = fake_sync with patch("builtins.print"): cmd_clone(_args(corr_nr="DEVK001"), None) # 同步返回后,临时文件应已删除 self.assertFalse(os.path.isfile(captured["path"])) @patch("sapcli.commands.clone._make_profile_client") @patch("sapcli.commands.clone._sync_single") @patch("sapcli.commands.clone.os.unlink", side_effect=OSError("busy")) def test_temp_file_unlink_error_swallowed(self, mock_unlink, mock_sync, mock_make): """os.unlink 抛 OSError 时被吞掉,不影响主流程。""" from sapcli.commands.clone import cmd_clone src_client = MagicMock() src_client.object_exists.return_value = True src_client.get_source.return_value = "REPORT ztest." tgt_client = MagicMock() tgt_client.object_exists.return_value = True mock_make.side_effect = [src_client, tgt_client] mock_sync.return_value = (True, None, "DEVK001") # 不应抛异常(OSError 在 finally 中被吞) with patch("builtins.print"): cmd_clone(_args(corr_nr="DEVK001"), None) mock_unlink.assert_called_once() if __name__ == "__main__": unittest.main(verbosity=2)