Files
吴让宇 86519c51d6 fix(info): 406 回退值改为 */* —— 原值 application/xml 本身也返回 406
`info` 在 NW 7.40 上对 DDIC 类型恒报「查询失败: HTTP 406」,实测根因:
回退分支把 Accept 从专属媒体类型换成 `application/xml`,而该值在本系统同样返回 406,
等于没回退。真实客户端 `_headers()` 的 Accept 默认值本就是 `*/*`,此处相当于
把可用默认值覆盖成了失效值。

实测(S4T / NW 7.40,structure ZMM_BIP_001_HEADER):
  application/vnd.sap.adt.ddic.structures.v1|v2|v3+xml → 406
  application/xml                                      → 406
  */*                                                  → 200
修后真机验证:`info --name ZMM_BIP_001_HEADER --type structure` 查询成功。

已知限制(非本修复引入):NW 7.40 的 DDIC 端点在 */* 下只返回 blueSource,
不含 description/packageRef,故 structure/tabletype 等类型的「描述/开发包/负责人」
为空。需这些字段时用 read-table 查 DD02T(描述)/ TADIR(开发包)。
(objectproperties 端点在本系统返回 422,不可用。)

测试:
- 新增 test_info_406_fallback_accept_is_wildcard —— 断言回退 Accept 必须为 */*,
  且明确排除 application/xml。**必须用 side_effect 在请求发生时快照 headers**:
  cmd_info 重试时复用同一个 hdrs dict,直接读 call_args_list[i].kwargs["headers"]
  会因别名而全部显示最后一次赋的值,令该断言永远无法成立。
- 三个测试文件的 _make_client() 由 `_headers.return_value={...}`(共享 dict)
  改为 side_effect 返回新 dict,与真实实现一致——原写法会让被记录的 headers
  全部指向同一对象,使 header 断言失真。
- 反向验证:把回退改回 application/xml → 测试报红 AssertionError('application/xml' != '*/*')。

699 tests OK。
2026-09-11 00:53:02 +08:00

1125 lines
48 KiB
Python
Raw Permalink 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.
"""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'<?xml version="1.0"?>'
b'<adtcore:object xmlns:adtcore="http://www.sap.com/adt/core"'
b' adtcore:name="' + name.encode() + b'" adtcore:type="PROG/P"'
b' adtcore:version="' + version.encode() + b'" adtcore:description="d"'
)
if package is None:
return head + b'/>'
return (
head + b'>'
b'<adtcore:packageRef adtcore:name="' + package.encode() + b'"/>'
b'</adtcore:object>'
)
# ═══════════════════════════════════════════
# 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)