"""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)