"""commands/crud.py 深入单元测试 — 补全 _sync_single / cmd_info / cmd_delete / _select_transport_request / cmd_create / _create_ddic / manifest 更新等分支。 运行: python tests/unit/test_crud_extra.py """ from __future__ import annotations import argparse import json import os import sys import tempfile import unittest from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) from sapcli.client import ADTClient from sapcli.exceptions import ( CreateError, DeleteError, InvalidNameError, LockError, ObjectNotFoundError, SapCliError, ) # ── helpers ────────────────────────────────────────────────────── def _mock_resp(status_code=200, text="", content=b"", headers=None): r = MagicMock() r.status_code = status_code r.text = text r.content = content r.headers = headers or {} r.raise_for_status = MagicMock() return r def _make_client(): """创建完全 mock 的 ADTClient。""" client = MagicMock(spec=ADTClient) client.host = "https://sap.example.com" client.sap_client = "100" client.csrf_token = "test-csrf-token" client.user = "TESTUSER" client.session = MagicMock() # 真实 _headers() 每次返回**新** dict。用 return_value 会让所有被记录的 # kwargs["headers"] 指向同一对象——后续赋值会覆盖历史值,令 header 断言失真 # (实测:406 回退的 Accept 断言曾被此别名效应蒙过)。 client._headers.side_effect = lambda content_type="application/xml": { "content-type": content_type, "x-csrf-token": "test-csrf-token", "Accept": "*/*", } return client def _args(**kwargs): defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} defaults.update(kwargs) return argparse.Namespace(**defaults) def _write_source(content="REPORT ztest.\nWRITE: / 'hello'.\n"): """写入临时 .abap 文件,返回路径。""" f = tempfile.NamedTemporaryFile( mode="w", suffix=".abap", delete=False, encoding="utf-8" ) f.write(content) f.close() return f.name def _info_xml(name="ZTEST", version="active", package=None): head = ( b'' b'' return ( head + b'>' b'' b'' ) # ═══════════════════════════════════════════ # cmd_download / cmd_sync — 无源码类型 # ═══════════════════════════════════════════ class TestCmdDownloadNoSource(unittest.TestCase): def test_download_functiongroup_raises(self): from sapcli.commands.crud import cmd_download client = _make_client() with self.assertRaises(InvalidNameError): cmd_download( _args(type="functiongroup", name="ZFG", path="."), client ) client.get_source.assert_not_called() class TestCmdSyncNoSource(unittest.TestCase): def test_sync_functiongroup_raises(self): from sapcli.commands.crud import cmd_sync client = _make_client() tmp = _write_source() try: with self.assertRaises(InvalidNameError): cmd_sync( _args(type="functiongroup", name="ZFG", path=tmp), client ) finally: os.unlink(tmp) # ═══════════════════════════════════════════ # cmd_sync — project_path 探测 + 成功/失败清单更新 # ═══════════════════════════════════════════ class TestCmdSyncProjectPathDetection(unittest.TestCase): @patch("sapcli.commands.crud._sync_single") def test_detects_project_path_from_manifest(self, mock_sync): """文件同目录有 manifest.json → project_path 被设为该目录。""" from sapcli.commands.crud import cmd_sync client = _make_client() mock_sync.return_value = (True, None, "DEVK001") with tempfile.TemporaryDirectory() as td: filepath = os.path.join(td, "ztest.abap") with open(filepath, "w", encoding="utf-8") as f: f.write("REPORT ztest.") with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) args = _args(type="report", name="ZTEST", path=filepath) cmd_sync(args, client) self.assertEqual(args.project_path, td) @patch("sapcli.commands.crud._sync_single") def test_no_manifest_sets_project_path_none(self, mock_sync): """文件同目录无 manifest.json → project_path = None。""" from sapcli.commands.crud import cmd_sync client = _make_client() mock_sync.return_value = (True, None, None) with tempfile.TemporaryDirectory() as td: filepath = os.path.join(td, "ztest.abap") with open(filepath, "w", encoding="utf-8") as f: f.write("REPORT ztest.") args = _args(type="report", name="ZTEST", path=filepath) cmd_sync(args, client) self.assertIsNone(args.project_path) @patch("sapcli.commands.crud._sync_single") def test_sync_failure_raises_and_updates_manifest(self, mock_sync): """sync 失败时调用 _update_manifest_after_sync(failed=True) 并抛 SapCliError。""" from sapcli.commands.crud import cmd_sync client = _make_client() mock_sync.return_value = (False, "激活失败: 行3", "DEVK001") with tempfile.TemporaryDirectory() as td: filepath = os.path.join(td, "ztest.abap") with open(filepath, "w", encoding="utf-8") as f: f.write("REPORT ztest.") with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) args = _args(type="report", name="ZTEST", path=filepath) with self.assertRaises(SapCliError): cmd_sync(args, client) # 清单被写入失败标记 with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: data = json.load(f) self.assertEqual( data["objects"]["ZTEST"]["last_sync_result"], "failed" ) # ═══════════════════════════════════════════ # _sync_single — 各分支 # ═══════════════════════════════════════════ class TestSyncSingleBranches(unittest.TestCase): """_sync_single 核心同步逻辑的各种分支。""" def test_no_source_type_returns_false(self): from sapcli.commands.crud import _sync_single client = _make_client() ok, err, corr = _sync_single("ZFG", "functiongroup", "/x.abap", client) self.assertFalse(ok) self.assertIn("没有源代码", err) self.assertIsNone(corr) def test_file_not_found_returns_false(self): from sapcli.commands.crud import _sync_single client = _make_client() ok, err, corr = _sync_single( "ZTEST", "report", "/nonexistent/no.abap", client ) self.assertFalse(ok) self.assertIn("文件不存在", err) self.assertIsNone(corr) def test_object_not_exists_auto_creates(self): """对象不存在时自动 create_object。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = False client.lock.return_value = ("lh_1", "DEVK001") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") self.assertTrue(ok) finally: os.unlink(tmp) client.create_object.assert_called_once() def test_create_object_failure_returns_false(self): """自动创建对象失败 → 返回 False。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = False client.create_object.side_effect = Exception("create boom") tmp = _write_source() try: ok, err, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertFalse(ok) self.assertIn("创建失败", err) def test_lock_with_corr_nr(self): """指定 corr_nr 时用该号锁定。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", "DEVK001") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertTrue(ok) self.assertEqual(corr, "DEVK001") # lock 第二位置参数为 corr_nr self.assertEqual(client.lock.call_args[0][1], "DEVK001") def test_lock_without_corr_detects_transport(self): """无 corr_nr 但 lock 返回检测到的传输请求号。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", "DEVK901999") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, corr = _sync_single("ZTEST", "report", tmp, client) finally: os.unlink(tmp) self.assertTrue(ok) self.assertEqual(corr, "DEVK901999") def test_lock_without_corr_local_object(self): """无 corr_nr 且 lock 未返回传输请求号 → 本地对象。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", None) client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, corr = _sync_single("ZTEST", "report", tmp, client) finally: os.unlink(tmp) self.assertTrue(ok) self.assertIsNone(corr) @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") def test_lock_error_then_select_transport(self, mock_select): """lock 抛 LockError → 交互式选择传输请求后重试锁定。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.side_effect = [LockError("need corr"), ("lh_1", "DEVK001")] client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, corr = _sync_single("ZTEST", "report", tmp, client) finally: os.unlink(tmp) self.assertTrue(ok) self.assertEqual(corr, "DEVK001") mock_select.assert_called_once() @patch("sapcli.commands.crud._select_transport_request", return_value=None) def test_lock_error_select_returns_none_fails(self, mock_select): """lock 抛 LockError 且无法获取传输请求号 → 返回失败。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.side_effect = [LockError("need corr")] tmp = _write_source() try: ok, err, corr = _sync_single("ZTEST", "report", tmp, client) finally: os.unlink(tmp) self.assertFalse(ok) self.assertIn("传输请求号", err) def test_lock_error_outer_caught(self): """带 corr_nr 时 lock 直接抛 LockError(不走选择)→ 返回失败。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.side_effect = LockError("locked by other") tmp = _write_source() try: ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertFalse(ok) self.assertIn("locked by other", err) def test_syntax_check_failure_returns_false(self): """语法检查未通过 → 返回失败,列出错误。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", "DEVK001") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = ( False, [ {"type": "E", "line": "10", "text": "field missing"}, {"type": "W", "line": "5", "text": "warning msg"}, ], ) tmp = _write_source() try: ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertFalse(ok) self.assertIn("语法检查未通过", err) self.assertIn("10", err) self.assertEqual(corr, "DEVK001") client.activate.assert_not_called() def test_syntax_check_exception_skips_to_activate(self): """语法检查抛异常 → 跳过检查直接激活。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", "DEVK001") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.side_effect = Exception("check svc down") client.activate.return_value = (True, []) tmp = _write_source() try: ok, _, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertTrue(ok) client.activate.assert_called_once() def test_activation_failure_returns_false(self): """激活失败 → 返回失败并列出错误。""" from sapcli.commands.crud import _sync_single client = _make_client() client.object_exists.return_value = True client.lock.return_value = ("lh_1", "DEVK001") client.set_source.return_value = True client.unlock.return_value = True client.syntax_check.return_value = (True, []) client.activate.return_value = ( False, [ {"type": "E", "line": "3", "text": "act error"}, {"type": "W", "line": "1", "text": "act warn"}, ], ) tmp = _write_source() try: ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") finally: os.unlink(tmp) self.assertFalse(ok) self.assertIn("激活失败", err) self.assertIn("3", err) # ═══════════════════════════════════════════ # _update_manifest_after_sync # ═══════════════════════════════════════════ class TestUpdateManifestAfterSync(unittest.TestCase): def _manifest_with_entry(self, td, name="ZTEST"): path = os.path.join(td, "manifest.json") with open(path, "w", encoding="utf-8") as f: json.dump( { "version": 1, "objects": { name: { "type": "report", "file": "reports/ztest.abap", "system_status": "active", } }, }, f, ) return path def test_no_project_path_returns(self): from sapcli.commands.crud import _update_manifest_after_sync _update_manifest_after_sync( argparse.Namespace(project_path=None), "ZTEST", "report", "/x.abap", None ) def test_no_manifest_file_returns(self): from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None ) def test_existing_entry_updated_success(self): from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: mpath = self._manifest_with_entry(td) _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZTEST", "report", os.path.join(td, "ztest.abap"), "DEVK001", ) with open(mpath, encoding="utf-8") as f: data = json.load(f) self.assertEqual(data["objects"]["ZTEST"]["corr_nr"], "DEVK001") self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "success") self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") def test_existing_entry_failure_keeps_status(self): from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: mpath = self._manifest_with_entry(td) _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZTEST", "report", os.path.join(td, "ztest.abap"), "DEVK001", failed=True, ) with open(mpath, encoding="utf-8") as f: data = json.load(f) self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "failed") # failed 时 system_status 不变(仍 active) self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") def test_new_entry_upserted(self): from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZNEW", "report", os.path.join(td, "reports", "znew.abap"), "DEVK001", ) with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: data = json.load(f) self.assertIn("ZNEW", data["objects"]) self.assertEqual(data["objects"]["ZNEW"]["system_status"], "active") def test_new_entry_failure_marked_inactive(self): from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZNEW", "report", os.path.join(td, "znew.abap"), None, failed=True, ) with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: data = json.load(f) self.assertEqual(data["objects"]["ZNEW"]["system_status"], "inactive") self.assertEqual(data["objects"]["ZNEW"]["last_sync_result"], "failed") def test_corrupt_manifest_exception_swallowed(self): """Manifest.load 抛异常时被吞掉,不传播。""" from sapcli.commands.crud import _update_manifest_after_sync with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: f.write("NOT VALID JSON") # 不抛异常 _update_manifest_after_sync( argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None ) # ═══════════════════════════════════════════ # cmd_info — 状态图标 # ═══════════════════════════════════════════ class TestCmdInfoStatusIcons(unittest.TestCase): def test_inactive_version_icon(self): from sapcli.commands.crud import cmd_info client = _make_client() client.session.get.return_value = _mock_resp(200, content=_info_xml(version="inactive")) with patch("builtins.print") as mock_print: cmd_info(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("未激活", printed) def test_unknown_version_icon(self): from sapcli.commands.crud import cmd_info client = _make_client() client.session.get.return_value = _mock_resp(200, content=_info_xml(version="draft")) with patch("builtins.print") as mock_print: cmd_info(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("draft", printed) # ═══════════════════════════════════════════ # cmd_info — 开发包字段 # ═══════════════════════════════════════════ class TestCmdInfoPackage(unittest.TestCase): def test_package_displayed(self): from sapcli.commands.crud import cmd_info client = _make_client() client.session.get.return_value = _mock_resp( 200, content=_info_xml(package="ZMY_PKG") ) client.query_table_data.return_value = {"rows": []} with patch("builtins.print") as mock_print: cmd_info(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("开发包", printed) self.assertIn("ZMY_PKG", printed) def test_package_missing_shows_unspecified(self): from sapcli.commands.crud import cmd_info client = _make_client() client.session.get.return_value = _mock_resp(200, content=_info_xml()) client.query_table_data.return_value = {"rows": []} with patch("builtins.print") as mock_print: cmd_info(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("(未指定)", printed) # ═══════════════════════════════════════════ # cmd_delete — 分支 # ═══════════════════════════════════════════ class TestCmdDeleteBranches(unittest.TestCase): def test_delete_object_not_found_raises(self): from sapcli.commands.crud import cmd_delete client = _make_client() client.object_exists.return_value = False with self.assertRaises(ObjectNotFoundError): cmd_delete(_args(), client) @patch("builtins.input", return_value="no") def test_delete_cancelled_returns(self, mock_input): from sapcli.commands.crud import cmd_delete client = _make_client() client.object_exists.return_value = True with patch("builtins.print") as mock_print: cmd_delete(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("取消", printed) client.delete_object.assert_not_called() @patch("builtins.input", return_value="yes") def test_delete_no_corr_nr(self, mock_input): """无传输请求号时仍尝试删除。""" from sapcli.commands.crud import cmd_delete client = _make_client() client.object_exists.return_value = True client.get_transport_request.return_value = None client.delete_object.return_value = (True, "") with patch("builtins.print") as mock_print: cmd_delete(_args(), client) printed = " ".join(str(c) for c, _ in mock_print.call_args_list) self.assertIn("无传输号", printed) client.delete_object.assert_called_once() @patch("builtins.input", return_value="yes") def test_delete_failure_raises(self, mock_input): from sapcli.commands.crud import cmd_delete client = _make_client() client.object_exists.return_value = True client.get_transport_request.return_value = "DEVK001" client.delete_object.return_value = (False, "locked") with patch("builtins.print"): with self.assertRaises(DeleteError) as ctx: cmd_delete(_args(), client) self.assertIn("locked", str(ctx.exception)) # ═══════════════════════════════════════════ # _update_manifest_after_delete # ═══════════════════════════════════════════ class TestUpdateManifestAfterDelete(unittest.TestCase): def test_no_project_path_returns(self): from sapcli.commands.crud import _update_manifest_after_delete _update_manifest_after_delete(argparse.Namespace(project_path=None), "ZTEST") def test_no_manifest_file_returns(self): from sapcli.commands.crud import _update_manifest_after_delete with tempfile.TemporaryDirectory() as td: _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") def test_removes_existing_entry(self): from sapcli.commands.crud import _update_manifest_after_delete with tempfile.TemporaryDirectory() as td: mpath = os.path.join(td, "manifest.json") with open(mpath, "w", encoding="utf-8") as f: json.dump( {"version": 1, "objects": {"ZTEST": {"type": "report", "file": "x.abap"}}}, f, ) _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") with open(mpath, encoding="utf-8") as f: data = json.load(f) self.assertNotIn("ZTEST", data["objects"]) def test_remove_nonexistent_entry_no_save(self): """移除不存在的条目时 remove 返回 False,不保存。""" from sapcli.commands.crud import _update_manifest_after_delete with tempfile.TemporaryDirectory() as td: mpath = os.path.join(td, "manifest.json") with open(mpath, "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) mtime_before = os.path.getmtime(mpath) _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZNONE") # 不抛异常即可(remove 返回 False → 不 save) def test_corrupt_manifest_exception_swallowed(self): from sapcli.commands.crud import _update_manifest_after_delete with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: f.write("BAD JSON") _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") # ═══════════════════════════════════════════ # _select_transport_request — 交互式 # ═══════════════════════════════════════════ class TestSelectTransportRequest(unittest.TestCase): def test_api_error_returns_none(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.side_effect = Exception("fail") with patch("builtins.print"): self.assertIsNone(_select_transport_request(client)) def test_choose_existing_by_index(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, {"number": "DEVK002", "description": "b", "owner": "u"}, ] with patch("builtins.input", return_value="1"): result = _select_transport_request(client) self.assertEqual(result, "DEVK001") def test_choose_zero_no_transport(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] with patch("builtins.input", return_value="0"): result = _select_transport_request(client) self.assertIsNone(result) def test_choose_new_request_success(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, {"number": "DEVK002", "description": "b", "owner": "u"}, ] # "3" = 新建(2 + 1),随后输入描述 with patch("builtins.input", side_effect=["3", "new feature"]): client.create_transport_request.return_value = "DEVK999" result = _select_transport_request(client) self.assertEqual(result, "DEVK999") def test_choose_new_request_create_returns_none(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] # "2" = 新建,描述,create 返回 None with patch("builtins.input", side_effect=["2", "desc"]): client.create_transport_request.return_value = None result = _select_transport_request(client) self.assertIsNone(result) def test_choose_new_request_create_raises(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] with patch("builtins.input", side_effect=["2", "desc"]): client.create_transport_request.side_effect = Exception("no perm") result = _select_transport_request(client) self.assertIsNone(result) def test_invalid_input_then_valid(self): """非数字输入后重新输入有效索引。""" from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] with patch("builtins.input", side_effect=["abc", "1"]): result = _select_transport_request(client) self.assertEqual(result, "DEVK001") def test_empty_input_then_valid(self): """空输入(直接回车)时重新提示。""" from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] with patch("builtins.input", side_effect=["", "1"]): result = _select_transport_request(client) self.assertEqual(result, "DEVK001") def test_new_request_empty_desc_then_valid(self): """新建请求但描述为空 → 提示后重新选择,再选已有请求。""" from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] # "2"=新建, ""=空描述(continue), "1"=选 DEVK001 with patch("builtins.input", side_effect=["2", "", "1"]): result = _select_transport_request(client) self.assertEqual(result, "DEVK001") def test_out_of_range_then_valid(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [ {"number": "DEVK001", "description": "a", "owner": "u"}, ] with patch("builtins.input", side_effect=["99", "1"]): result = _select_transport_request(client) self.assertEqual(result, "DEVK001") def test_empty_list_create_new(self): """无可修改请求时,选择新建。""" from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [] with patch("builtins.input", side_effect=["y", "new desc"]): client.create_transport_request.return_value = "DEVK888" result = _select_transport_request(client) self.assertEqual(result, "DEVK888") def test_empty_list_decline(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [] with patch("builtins.input", return_value="n"): result = _select_transport_request(client) self.assertIsNone(result) def test_empty_list_empty_description(self): """空列表 + 选择新建但描述为空 → 无传输号创建。""" from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [] with patch("builtins.input", side_effect=["y", ""]): result = _select_transport_request(client) self.assertIsNone(result) def test_empty_list_create_raises(self): from sapcli.commands.crud import _select_transport_request client = _make_client() client.list_transport_requests.return_value = [] with patch("builtins.input", side_effect=["y", "desc"]): client.create_transport_request.side_effect = Exception("err") result = _select_transport_request(client) self.assertIsNone(result) # ═══════════════════════════════════════════ # cmd_create — function / functiongroup / 异常 # ═══════════════════════════════════════════ class TestCmdCreateBranches(unittest.TestCase): def test_create_function_without_slash_raises(self): from sapcli.commands.crud import cmd_create client = _make_client() with self.assertRaises(InvalidNameError): cmd_create( _args(type="function", name="Z_NO_SLASH", source=None, description="d", package="$TMP", corr_nr="DEVK001"), client, ) def test_create_object_already_exists_raises(self): """非 functiongroup 类型且对象已存在 → ObjectAlreadyExistsError。""" from sapcli.commands.crud import cmd_create from sapcli.exceptions import ObjectAlreadyExistsError client = _make_client() client.object_exists.return_value = True with self.assertRaises(ObjectAlreadyExistsError): cmd_create( _args(type="class", name="ZCL_EXISTS", source=None, description="d", package="$TMP", corr_nr="DEVK001"), client, ) @patch("sapcli.commands.crud.Manifest") def test_create_corr_nr_specified_skips_select(self, MockManifest): from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.create_object.return_value = ("/uri/z", "/uri/z/source") cmd_create( _args(type="report", name="ZTEST", source=None, description="d", package="$TMP", corr_nr="DEVK001"), client, ) client.create_object.assert_called_once() @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") @patch("sapcli.commands.crud.Manifest") def test_create_function_creates_group(self, MockManifest, mock_transport): """function 类型且函数组不存在 → 自动创建函数组。""" from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.function_group_exists.return_value = False client.create_function_group.return_value = "/uri/group" client.create_object.return_value = ("/uri/func", "/uri/func/source") cmd_create( _args(type="function", name="ZGRP/Z_FUNC", source=None, description="d", package="$TMP", corr_nr=None), client, ) client.create_function_group.assert_called_once() @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") @patch("sapcli.commands.crud.Manifest") def test_create_function_group_exists(self, MockManifest, mock_transport): """function 类型且函数组已存在 → 跳过创建。""" from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.function_group_exists.return_value = True client.create_object.return_value = ("/uri/func", "/uri/func/source") cmd_create( _args(type="function", name="ZGRP/Z_FUNC", source=None, description="d", package="$TMP", corr_nr=None), client, ) client.create_function_group.assert_not_called() @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") def test_create_function_group_create_raises(self, mock_transport): """function 函数组创建失败 → CreateError。""" from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.function_group_exists.return_value = False client.create_function_group.side_effect = Exception("group fail") with self.assertRaises(CreateError): cmd_create( _args(type="function", name="ZGRP/Z_FUNC", source=None, description="d", package="$TMP", corr_nr=None), client, ) @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") def test_create_functiongroup_create_raises(self, mock_transport): """functiongroup 创建失败 → CreateError。""" from sapcli.commands.crud import cmd_create client = _make_client() client.function_group_exists.return_value = False client.create_function_group.side_effect = Exception("boom") with self.assertRaises(CreateError): cmd_create( _args(type="functiongroup", name="ZFG", source=None, description="d", package="$TMP", corr_nr=None), client, ) @patch("sapcli.commands.crud.Manifest") def test_create_object_raises(self, MockManifest): from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.create_object.side_effect = Exception("create boom") with self.assertRaises(CreateError): cmd_create( _args(type="report", name="ZTEST", source=None, description="d", package="$TMP", corr_nr="DEVK001"), client, ) # ═══════════════════════════════════════════ # _update_manifest_after_create # ═══════════════════════════════════════════ class TestUpdateManifestAfterCreate(unittest.TestCase): def test_no_project_path_returns(self): from sapcli.commands.crud import _update_manifest_after_create _update_manifest_after_create( argparse.Namespace(project_path=None), "ZTEST", "report", "DEVK001" ) def test_no_manifest_file_returns(self): from sapcli.commands.crud import _update_manifest_after_create with tempfile.TemporaryDirectory() as td: _update_manifest_after_create( argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" ) def test_report_gets_reports_dir(self): from sapcli.commands.crud import _update_manifest_after_create with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) _update_manifest_after_create( argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" ) with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: data = json.load(f) self.assertEqual(data["objects"]["ZTEST"]["file"], "reports/ztest.abap") def test_function_gets_functions_dir(self): from sapcli.commands.crud import _update_manifest_after_create with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: json.dump({"version": 1, "objects": {}}, f) _update_manifest_after_create( argparse.Namespace(project_path=td), "ZGRP/Z_FUNC", "function", None ) with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: data = json.load(f) self.assertEqual( data["objects"]["ZGRP/Z_FUNC"]["file"], "functions/zgrp/z_func.abap" ) def test_corrupt_manifest_exception_swallowed(self): from sapcli.commands.crud import _update_manifest_after_create with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: f.write("BAD") _update_manifest_after_create( argparse.Namespace(project_path=td), "ZTEST", "report", None ) # ═══════════════════════════════════════════ # _create_ddic — 各类型 + 默认模板 + 异常 # ═══════════════════════════════════════════ class TestCreateDdic(unittest.TestCase): def _run_create(self, obj_type, name, definition_file=None): from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.create_ddic_object.return_value = ("/uri", "/uri/source") with patch("sapcli.commands.crud.Manifest"): cmd_create( _args(type=obj_type, name=name, source=None, definition=definition_file, description="d", package="$TMP", corr_nr="DEVK001"), client, ) return client def _write_def(self, payload): f = tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False, encoding="utf-8" ) json.dump(payload, f) f.close() return f.name def test_structure_with_definition(self): from sapcli.commands.crud import cmd_create deffile = self._write_def({ "fields": [{"name": "F1", "type": "char10"}], "enhancement_category": "#NOT_CLASSIFIED", }) try: client = self._run_create("structure", "ZSTRUCT", deffile) client.create_ddic_object.assert_called_once() finally: os.unlink(deffile) def test_tabletype_with_definition(self): from sapcli.commands.crud import cmd_create deffile = self._write_def({ "line_type": "ZSTRUCT", "key_type": "#USER_DEFINED", "access_mode": "#STANDARD", }) try: client = self._run_create("tabletype", "ZTT", deffile) client.create_ddic_object.assert_called_once() finally: os.unlink(deffile) def test_domain_default_template(self): """domain 无 definition → 默认 DomainDefinition 模板。""" client = self._run_create("domain", "ZDOM", None) client.create_ddic_object.assert_called_once() body = client.create_ddic_object.call_args[0][2] # 默认模板应生成非空 XML body self.assertIsInstance(body, str) self.assertTrue(len(body) > 0) def test_table_default_template(self): """table 无 definition → 默认 TableDefinition 模板。""" client = self._run_create("table", "ZTAB", None) client.create_ddic_object.assert_called_once() def test_dataelement_default_template(self): """dataelement 无 definition → 默认模板。""" client = self._run_create("dataelement", "ZDE", None) client.create_ddic_object.assert_called_once() def test_structure_default_template(self): """structure 无 definition → 默认 StructureDefinition 模板。""" client = self._run_create("structure", "ZSTRUCT", None) client.create_ddic_object.assert_called_once() def test_create_ddic_object_raises(self): """create_ddic_object 失败 → CreateError。""" from sapcli.commands.crud import cmd_create client = _make_client() client.object_exists.return_value = False client.create_ddic_object.side_effect = Exception("ddic fail") with patch("sapcli.commands.crud.Manifest"): with self.assertRaises(CreateError): cmd_create( _args(type="domain", name="ZDOM", source=None, definition=None, description="d", package="$TMP", corr_nr="DEVK001"), client, ) def test_domain_with_definition_fix_values(self): """domain definition 含 fix_values → 仍走 to_xml。""" from sapcli.commands.crud import cmd_create deffile = self._write_def({ "datatype": "CHAR", "length": 1, "fix_values": [{"low": "A", "text": "Active"}], }) try: client = self._run_create("domain", "ZDOM", deffile) client.create_ddic_object.assert_called_once() finally: os.unlink(deffile) if __name__ == "__main__": unittest.main(verbosity=2)