"""client/_ddic.py 单元测试 — DdicMixin 的 DDIC/CRUD/查询/版本/增强方法。
与 test_client.py 互补:后者覆盖 _ddic 的部分公共方法,本文件聚焦
未覆盖的方法与分支(function 创建、各 _build_create_body 类型、DDIC 写入、
表查询、ABAP Unit、版本历史、增强实现、XML namespace helpers 等)。
运行: python -m pytest tests/unit/test_ddic.py -v
"""
from __future__ import annotations
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
from sapcli.client import ADTClient
from sapcli.client._ddic import _local, _attr_local, _find_local
from sapcli.exceptions import CreateError, DeleteError, SapCliError
from sapcli.types import parse_object_name
# ADT XML 命名空间
ADTCORE_NS = "http://www.sap.com/adt/core"
LOCK_NS = "http://www.sap.com/adt/lock"
DP_NS = "http://www.sap.com/adt/dataPreview"
def _make_client() -> ADTClient:
"""创建 mock 好的 ADTClient(绕过 __init__,手动注入依赖)。"""
client = ADTClient.__new__(ADTClient)
client.host = "https://sap.example.com"
client.sap_client = "100"
client.user = "TESTUSER"
client.password = "TESTPASS"
client.csrf_token = "test-csrf-token"
client.session = MagicMock()
client._stateful = False
return client
def _mock_resp(status_code=200, text="", content=b"", headers=None):
"""快速创建 mock response。"""
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.content = content
resp.headers = headers or {}
resp.raise_for_status = MagicMock()
return resp
def _lock_resp(handle="lh", corr=""):
"""构造 lock POST 成功响应。"""
return _mock_resp(200, content=(
f'{handle}'
f'{corr}'
).encode())
# ═══════════════════════════════════════════
# 模块级 XML namespace helpers
# ═══════════════════════════════════════════
class TestLocalHelpers(unittest.TestCase):
"""_local / _attr_local / _find_local — 本地名匹配,忽略命名空间。"""
def test_local_with_namespace(self):
self.assertEqual(_local("{http://www.sap.com/adt/core}name"), "name")
def test_local_without_namespace(self):
self.assertEqual(_local("name"), "name")
def test_local_empty(self):
self.assertEqual(_local(""), "")
def test_attr_local_found(self):
el = ET.fromstring(f'')
self.assertEqual(_attr_local(el, "name"), "ZTEST")
def test_attr_local_plain_name(self):
"""无命名空间前缀的属性也能匹配。"""
el = ET.fromstring("")
self.assertEqual(_attr_local(el, "name"), "ZTEST")
def test_attr_local_missing_returns_empty(self):
el = ET.fromstring("")
self.assertEqual(_attr_local(el, "name"), "")
def test_find_local_found(self):
root = ET.fromstring(
f''
)
summary = _find_local(root, "summary")
self.assertIsNotNone(summary)
self.assertEqual(summary.attrib.get("tests"), "3")
def test_find_local_missing_returns_none(self):
root = ET.fromstring("")
self.assertIsNone(_find_local(root, "summary"))
# ═══════════════════════════════════════════
# delete_object — corr_nr 分支
# ═══════════════════════════════════════════
class TestDeleteObjectCorrNr(unittest.TestCase):
def test_with_corr_nr(self):
"""带 corr_nr 时 DELETE 请求参数应包含 corrNr。"""
client = _make_client()
client.session.post.return_value = _lock_resp(handle="lh_del")
client.session.delete.return_value = _mock_resp(204)
ok, msg = client.delete_object("/uri", corr_nr="DEVK001")
self.assertTrue(ok)
# 验证 DELETE 调用带上了 lockHandle + corrNr
_, kwargs = client.session.delete.call_args
self.assertEqual(kwargs["params"]["lockHandle"], "lh_del")
self.assertEqual(kwargs["params"]["corrNr"], "DEVK001")
# ═══════════════════════════════════════════
# parse_object_name — tabletype 存在性检查端点(缺陷2)
# ═══════════════════════════════════════════
class TestTableTypeExistsUri(unittest.TestCase):
"""tabletype 的存在性检查必须用 DDIC 端点,而非 VIT 端点。
VIT 端点 /vit/wb/object_type/ttypda/object_name/{name} 对任意名称都返回 200,
导致 create 永远走到"对象已存在"分支。obj_uri 仍保留 VIT 供 info 查询完整元数据。
"""
def test_tabletype_exists_uri_uses_ddic_endpoint(self):
parsed = parse_object_name("Z_MY_TT", "tabletype")
self.assertEqual(
parsed.exists_uri,
"/sap/bc/adt/ddic/tabletypes/z_my_tt",
)
def test_tabletype_obj_uri_still_vit_for_info(self):
"""obj_uri 仍是 VIT 端点(info 命令依赖它返回完整元数据)。"""
parsed = parse_object_name("Z_MY_TT", "tabletype")
self.assertIn("/vit/wb/object_type/ttypda/object_name/Z_MY_TT", parsed.obj_uri)
# exists_uri 必须不同于 VIT obj_uri,否则存在性检查失效
self.assertNotEqual(parsed.exists_uri, parsed.obj_uri)
def test_non_tabletype_exists_uri_equals_obj_uri(self):
"""非 tabletype 类型 exists_uri 回退为 obj_uri(行为不变)。"""
parsed = parse_object_name("ZTEST_DOM", "domain")
self.assertEqual(parsed.exists_uri, parsed.obj_uri)
# ═══════════════════════════════════════════
# create_object — function 分支 + corr_nr
# ═══════════════════════════════════════════
class TestCreateObjectFunction(unittest.TestCase):
def test_function_with_group(self):
"""function 类型 'GROUP/FM' 格式正确解析。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
obj_uri, src_uri = client.create_object("function", "ZGROUP/Z_FUNC", "Test fm")
# obj_uri 形如 /sap/bc/adt/functions/groups/zgroup/fmodules/z_func
self.assertIn("zgroup", obj_uri)
self.assertIn("z_func", obj_uri)
self.assertIsNotNone(src_uri)
def test_function_missing_slash_raises(self):
"""function 类型缺少 '/' 分隔符 → CreateError。"""
client = _make_client()
with self.assertRaises(CreateError):
client.create_object("function", "NOSLASH", "Test")
def test_with_corr_nr_passes_param(self):
"""带 corr_nr 时 POST 请求参数应包含 corrNr(覆盖 line 81)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
client.create_object("report", "ZTEST", "Test", corr_nr="DEVK001")
_, kwargs = client.session.post.call_args
self.assertEqual(kwargs["params"]["corrNr"], "DEVK001")
# ═══════════════════════════════════════════
# _build_create_body — 未覆盖的 6 种类型
# ═══════════════════════════════════════════
class TestBuildCreateBodyRemainingTypes(unittest.TestCase):
def _check(self, obj_type, name, expected_marker):
body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}")
self.assertIn(name, body)
self.assertIn(expected_marker, body)
def test_function(self):
self._check("function", "Z_FUNC", "abapFunctionModule")
def test_include(self):
self._check("include", "ZINCL", "PROG/I")
def test_include_uses_includes_namespace(self):
"""include 使用 programs/includes 命名空间 + abapInclude 元素(缺陷1)。
参考 abap-adt-api objectcreator.ts:
rootName="include:abapInclude",
nameSpace="http://www.sap.com/adt/programs/includes"
不应复用 report 的 ,否则 POST /programs/includes 返回 400。
"""
body = _make_client()._build_create_body("include", "ZINCL", "Test include")
self.assertIn("include:abapInclude", body)
self.assertIn("http://www.sap.com/adt/programs/includes", body)
self.assertNotIn("abapProgram", body)
def test_messageclass(self):
self._check("messageclass", "ZMSG", "t100MessageClass")
def test_view(self):
self._check("view", "ZVIEW", "dictionary/views")
def test_searchhelp(self):
self._check("searchhelp", "ZSHLP", "searchHelp")
def test_lockobject(self):
self._check("lockobject", "ZLOCK", "lockObject")
def test_description_defaults_to_name(self):
"""未提供 description 时用 name 作为描述。"""
body = _make_client()._build_create_body("include", "ZINCL", None)
self.assertIn('description="ZINCL"', body)
# ═══════════════════════════════════════════
# create_ddic_object — corr_nr + 激活失败
# ═══════════════════════════════════════════
_DDIC_DOMAIN_XML = (
''
''
''
''
)
class TestCreateDDICObjectExtra(unittest.TestCase):
def test_with_corr_nr(self):
"""带 corr_nr 走非 table 分支,POST 参数含 corrNr。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
with patch.object(client, "activate", return_value=(True, [])) as mock_act:
obj_uri, src_uri = client.create_ddic_object(
"domain", "ZTEST_DOM", _DDIC_DOMAIN_XML, corr_nr="DEVK001"
)
# POST 创建带 corrNr
_, kwargs = client.session.post.call_args
self.assertEqual(kwargs["params"]["corrNr"], "DEVK001")
# activate 收到 corr_nr
mock_act.assert_called_once()
self.assertIn("ztest_dom", obj_uri)
def test_activation_failure_with_errors_raises(self):
"""激活失败且含 type=E 消息 → CreateError。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
with patch.object(client, "activate", return_value=(
False, [{"type": "E", "text": "domain error"}]
)):
with self.assertRaises(CreateError) as ctx:
client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML)
self.assertIn("domain error", str(ctx.exception))
def test_activation_failure_no_errors_returns(self):
"""激活失败但无 type=E 消息 → 不抛异常,正常返回。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
with patch.object(client, "activate", return_value=(
False, [{"type": "W", "text": "warning only"}]
)):
obj_uri, _ = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML)
self.assertIn("ztest_dom", obj_uri)
def test_structure_posts_entity_then_puts_ddl(self):
"""structure 先 POST 创建对象实体,再 PUT 写入 DDL(缺陷3)。
之前 create_ddic_object 对 table/structure 直接 _put_ddl_source,
PUT 到尚未创建的对象 /source/main 返回 405。修复后先 POST 集合端点创建实体。
"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
with patch.object(client, "_put_ddl_source") as mock_ddl, \
patch.object(client, "activate", return_value=(True, [])):
obj_uri, _ = client.create_ddic_object("structure", "ZTEST_STR", "")
# 先 POST 创建对象实体
client.session.post.assert_called_once()
post_url = client.session.post.call_args[0][0]
self.assertIn("/ddic/structures", post_url)
# 再 PUT 写入 DDL 定义
mock_ddl.assert_called_once()
self.assertIn("ztest_str", obj_uri)
def test_table_posts_entity_then_puts_ddl(self):
"""table 同样先 POST 再 PUT。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
with patch.object(client, "_put_ddl_source") as mock_ddl, \
patch.object(client, "activate", return_value=(True, [])):
client.create_ddic_object("table", "ZTEST_TAB", "")
client.session.post.assert_called_once()
self.assertIn("/ddic/tables", client.session.post.call_args[0][0])
mock_ddl.assert_called_once()
def test_table_post_failure_raises_before_put(self):
"""POST 实体创建失败 → CreateError,且不执行 PUT。"""
client = _make_client()
client.session.post.return_value = _mock_resp(405, text="Method Not Allowed")
with patch.object(client, "_put_ddl_source") as mock_ddl, \
patch.object(client, "activate", return_value=(True, [])):
with self.assertRaises(CreateError):
client.create_ddic_object("table", "ZTEST_TAB", "")
mock_ddl.assert_not_called()
# ═══════════════════════════════════════════
# _put_ddic_xml
# ═══════════════════════════════════════════
class TestPutDdicXml(unittest.TestCase):
def test_success_with_lock_and_corr(self):
client = _make_client()
client.session.put.return_value = _mock_resp(200)
self.assertTrue(
client._put_ddic_xml("/uri", "", lock_handle="lh", corr_nr="DEVK001")
)
_, kwargs = client.session.put.call_args
self.assertEqual(kwargs["params"]["lockHandle"], "lh")
self.assertEqual(kwargs["params"]["corrNr"], "DEVK001")
self.assertEqual(kwargs["headers"]["Accept"], "*/*")
def test_success_no_lock_no_corr(self):
client = _make_client()
client.session.put.return_value = _mock_resp(200)
self.assertTrue(client._put_ddic_xml("/uri", ""))
_, kwargs = client.session.put.call_args
# 无 lock_handle/corr_nr 时不带对应参数
self.assertNotIn("lockHandle", kwargs["params"])
self.assertNotIn("corrNr", kwargs["params"])
def test_failure_raises_create_error(self):
client = _make_client()
client.session.put.return_value = _mock_resp(400, text="bad xml")
with self.assertRaises(CreateError):
client._put_ddic_xml("/uri", "")
# ═══════════════════════════════════════════
# _put_ddl_with_auto_lock
# ═══════════════════════════════════════════
class TestPutDdlWithAutoLock(unittest.TestCase):
def test_lock_succeeds_unlock_called(self):
client = _make_client()
with patch.object(client, "lock", return_value=("lh", "DEVK001")) as mock_lock, \
patch.object(client, "set_source", return_value=True) as mock_src, \
patch.object(client, "unlock") as mock_unlock:
result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri", corr_nr="DEVK001")
self.assertTrue(result)
mock_lock.assert_called_once_with("/uri", "DEVK001")
mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001")
mock_unlock.assert_called_once_with("/uri", "lh")
def test_lock_fails_no_unlock(self):
"""lock 抛异常 → 用空锁句柄继续,且不调 unlock。"""
client = _make_client()
with patch.object(client, "lock", side_effect=Exception("lock boom")), \
patch.object(client, "set_source", return_value=True) as mock_src, \
patch.object(client, "unlock") as mock_unlock:
result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri")
self.assertTrue(result)
# set_source 收到空 lock_handle
self.assertEqual(mock_src.call_args.args[2], "")
mock_unlock.assert_not_called()
# ═══════════════════════════════════════════
# _put_ddl_source
# ═══════════════════════════════════════════
class TestPutDdlSource(unittest.TestCase):
def test_delegates_to_set_source(self):
client = _make_client()
with patch.object(client, "set_source", return_value=True) as mock_src:
result = client._put_ddl_source("/src", "ddl", "lh", corr_nr="DEVK001")
self.assertTrue(result)
mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001")
# ═══════════════════════════════════════════
# get_object_status — 全分支
# ═══════════════════════════════════════════
class TestGetObjectStatusExtra(unittest.TestCase):
def test_unexpected_status_returns_not_exists(self):
"""非 200/404 状态码 → not_exists(覆盖 warning 分支)。"""
client = _make_client()
client.session.get.return_value = _mock_resp(500)
result = client.get_object_status("/uri")
self.assertFalse(result["exists"])
self.assertEqual(result["status"], "not_exists")
def test_inactive_version(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
).encode())
with patch.object(client, "lock", return_value=("lh", "")), \
patch.object(client, "unlock", return_value=True):
result = client.get_object_status("/uri")
self.assertTrue(result["exists"])
self.assertEqual(result["status"], "inactive")
def test_custom_version_string(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
).encode())
with patch.object(client, "lock", return_value=("lh", "")), \
patch.object(client, "unlock", return_value=True):
result = client.get_object_status("/uri")
self.assertEqual(result["status"], "custom_v2")
def test_unparseable_content_stays_active(self):
"""XML 解析失败 → status 保持 active。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=b"<>")
with patch.object(client, "lock", return_value=("lh", "")), \
patch.object(client, "unlock", return_value=True):
result = client.get_object_status("/uri")
self.assertEqual(result["status"], "active")
def test_corr_nr_detected_via_lock_probe(self):
"""lock 探测返回 corr_nr → 写入结果。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
).encode())
with patch.object(client, "lock", return_value=("lh", "DEVK901999")), \
patch.object(client, "unlock", return_value=True) as mock_unlock:
result = client.get_object_status("/uri")
self.assertEqual(result["corr_nr"], "DEVK901999")
mock_unlock.assert_called_once_with("/uri", "lh")
def test_lock_probe_failure_keeps_corr_none(self):
"""lock 探测异常 → corr_nr 保持 None,不抛出。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
).encode())
with patch.object(client, "lock", side_effect=Exception("no perm")):
result = client.get_object_status("/uri")
self.assertIsNone(result["corr_nr"])
self.assertTrue(result["exists"])
# ═══════════════════════════════════════════
# create_function_group — corr_nr
# ═══════════════════════════════════════════
class TestCreateFunctionGroupCorrNr(unittest.TestCase):
def test_with_corr_nr(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201)
self.assertTrue(client.create_function_group("ZGROUP", corr_nr="DEVK001"))
_, kwargs = client.session.post.call_args
self.assertEqual(kwargs["params"]["groupname"], "ZGROUP")
self.assertEqual(kwargs["params"]["corrNr"], "DEVK001")
# ═══════════════════════════════════════════
# create_package — superpackage
# ═══════════════════════════════════════════
class TestCreatePackageSuperpackage(unittest.TestCase):
def test_with_superpackage(self):
"""带 superpackage → body 含 packageRef(覆盖 line 604)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
self.assertTrue(client.create_package("Z_TEST", "desc", superpackage="Z_PARENT"))
_, kwargs = client.session.post.call_args
body = kwargs["data"].decode("utf-8")
self.assertIn("packageRef", body)
self.assertIn("Z_PARENT", body)
def test_without_superpackage(self):
"""无 superpackage → body 以自闭合结尾。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
self.assertTrue(client.create_package("Z_TEST"))
_, kwargs = client.session.post.call_args
body = kwargs["data"].decode("utf-8")
self.assertNotIn("packageRef", body)
self.assertTrue(body.rstrip().endswith("/>"))
# ═══════════════════════════════════════════
# get_package_info — packageRef
# ═══════════════════════════════════════════
class TestGetPackageInfoSuperpackage(unittest.TestCase):
def test_with_package_ref(self):
"""XML 含 adtcore:packageRef → 解析 superpackage(覆盖 line 650)。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
f''
f''
).encode())
info = client.get_package_info("Z_TEST")
self.assertEqual(info["name"], "Z_TEST")
self.assertEqual(info["description"], "Test pkg")
self.assertEqual(info["owner"], "DEVUSER")
self.assertEqual(info["superpackage"], "Z_PARENT")
def test_without_package_ref(self):
"""XML 无 packageRef → superpackage 保持空字符串。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
).encode())
info = client.get_package_info("Z_TEST")
self.assertEqual(info["superpackage"], "")
# ═══════════════════════════════════════════
# atc_check — variant
# ═══════════════════════════════════════════
class TestATCCheckVariant(unittest.TestCase):
def test_with_variant(self):
"""带 variant → POST 参数含 variant(覆盖 line 677)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f''
f''
f''
).encode())
ok, findings = client.atc_check("ZTEST", "/uri", variant="MY_VARIANT")
self.assertTrue(ok)
_, kwargs = client.session.post.call_args
self.assertEqual(kwargs["params"]["variant"], "MY_VARIANT")
self.assertEqual(kwargs["params"]["context"], "/uri")
def test_findings_severity_one_treated_as_error(self):
"""severity="1" 同样视为错误。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f''
f''
f''
f''
).encode())
ok, findings = client.atc_check("ZTEST", "/uri")
self.assertFalse(ok)
self.assertEqual(len(findings), 1)
# ═══════════════════════════════════════════
# get_table_fields
# ═══════════════════════════════════════════
_TABLE_FIELDS_XML = (
f''
f''
f''
f''
f''
f''
f''
).encode()
class TestGetTableFields(unittest.TestCase):
def test_parses_fields(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML)
fields = client.get_table_fields("ZTEST_TAB")
self.assertEqual(len(fields), 2)
self.assertEqual(fields[0]["name"], "MANDT")
self.assertEqual(fields[0]["type"], "CHAR")
self.assertEqual(fields[0]["length"], "3")
self.assertEqual(fields[0]["description"], "Client")
self.assertEqual(fields[0]["key_attribute"], "true")
# 未声明 keyAttribute 时默认 "false"
self.assertEqual(fields[1]["key_attribute"], "false")
def test_uses_lowercase_table_name(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML)
client.get_table_fields("ZTEST_TAB")
url = client.session.get.call_args.args[0]
self.assertIn("/ztest_tab/", url)
def test_http_error_propagates(self):
client = _make_client()
mock = _mock_resp(500)
mock.raise_for_status.side_effect = Exception("500")
client.session.get.return_value = mock
with self.assertRaises(Exception):
client.get_table_fields("ZTEST_TAB")
# ═══════════════════════════════════════════
# query_table_data
# ═══════════════════════════════════════════
_FREESTYLE_XML = (
f''
f''
f''
f''
f'100200'
f''
f''
f''
f'A001A002'
f''
f'2'
f'0.012'
f''
).encode()
class TestQueryTableData(unittest.TestCase):
def test_parses_and_transposes(self):
"""列存储 → 行存储转置。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML)
result = client.query_table_data("SELECT * FROM ztest", max_rows=50)
self.assertEqual(result["columns"], ["MANDT", "MATNR"])
self.assertEqual(result["rows"], [["100", "A001"], ["200", "A002"]])
self.assertEqual(result["total_rows"], 2)
self.assertEqual(result["execution_time"], "0.012")
def test_max_rows_param_sent(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML)
client.query_table_data("SELECT * FROM ztest", max_rows=500)
_, kwargs = client.session.post.call_args
self.assertEqual(kwargs["params"]["rowNumber"], "500")
def test_columns_without_dataset_yields_empty_values(self):
"""某列无 dataSet → 该列补空字符串(覆盖 dataset is None 分支)。"""
xml = (
f''
f''
f''
f''
f'100'
f''
f''
f''
).encode()
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=xml)
result = client.query_table_data("SELECT * FROM ztest")
self.assertEqual(result["rows"], [["100", ""]])
def test_no_columns_returns_empty(self):
"""无任何 columns → 覆盖 max(..., default=0)。"""
xml = (
f''
f''
f'0'
f''
).encode()
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=xml)
result = client.query_table_data("SELECT * FROM ztest")
self.assertEqual(result["columns"], [])
self.assertEqual(result["rows"], [])
self.assertEqual(result["total_rows"], 0)
def test_http_400_raises_friendly_error(self):
client = _make_client()
client.session.post.return_value = _mock_resp(400, text="A Boolean expression was expected")
with self.assertRaises(SapCliError) as ctx:
client.query_table_data("SELECT * FROM t WHERE f='x'")
self.assertIn("IN", str(ctx.exception))
self.assertIn("空格", str(ctx.exception))
def test_http_400_carries_sap_message(self):
"""400 报错必须携带 SAP 原文(如「未知的列名称」),不能只给笼统提示。"""
client = _make_client()
client.session.post.return_value = _mock_resp(
400, text=''
'未知的列名称 "LANGUAGE" until runtime')
with self.assertRaises(SapCliError) as ctx:
client.query_table_data("SELECT LANGUAGE FROM dd02t")
self.assertIn("SAP 原文", str(ctx.exception))
self.assertIn("未知的列名称", str(ctx.exception))
# ═══════════════════════════════════════════
# run_program
# ═══════════════════════════════════════════
class TestRunProgram(unittest.TestCase):
def test_success_returns_text(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="PROGRAM OUTPUT")
self.assertEqual(client.run_program("ZSAPILOT_SETUP"), "PROGRAM OUTPUT")
def test_url_uses_lowercase_name(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="out")
client.run_program("ZPROG")
url = client.session.post.call_args.args[0]
self.assertIn("/programrun/zprog", url)
def test_http_error_propagates(self):
client = _make_client()
mock = _mock_resp(500)
mock.raise_for_status.side_effect = Exception("500")
client.session.post.return_value = mock
with self.assertRaises(Exception):
client.run_program("ZPROG")
# ═══════════════════════════════════════════
# run_unit_test + _parse_aunit_result
# ═══════════════════════════════════════════
_AUNIT_XML = (
''
''
''
''
''
''
''
''
''
''
''
''
''
''
''
''
''
''
).encode()
class TestRunUnitTest(unittest.TestCase):
def test_success_returns_parsed(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_AUNIT_XML)
result = client.run_unit_test("/sap/bc/adt/oo/classes/zcl_test")
self.assertEqual(result["summary"]["tests"], "3")
self.assertEqual(result["summary"]["failures"], "1")
self.assertEqual(len(result["classes"]), 1)
# 验证 body 含 obj_uri
_, kwargs = client.session.post.call_args
self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8"))
def test_http_error_propagates(self):
client = _make_client()
mock = _mock_resp(500)
mock.raise_for_status.side_effect = Exception("500")
client.session.post.return_value = mock
with self.assertRaises(Exception):
client.run_unit_test("/uri")
class TestParseAunitResult(unittest.TestCase):
def test_full_parse(self):
result = _make_client()._parse_aunit_result(_AUNIT_XML)
self.assertEqual(result["summary"]["duration"], "450")
cls = result["classes"][0]
self.assertEqual(cls["name"], "ZCL_TEST")
methods = {m["name"]: m for m in cls["methods"]}
self.assertIn("test_foo", methods)
self.assertEqual(methods["test_foo"]["duration"], "0.45")
self.assertEqual(methods["test_foo"]["alert"], "assertion error")
self.assertEqual(methods["test_foo"]["line"], "42")
# test_bar 无 alert
self.assertEqual(methods["test_bar"]["alert"], "")
self.assertEqual(methods["test_bar"]["line"], "")
def test_empty_result_defaults(self):
"""无 summary/testClass → 默认值。"""
xml = (
''
''
).encode()
result = _make_client()._parse_aunit_result(xml)
self.assertEqual(result["summary"]["tests"], "0")
self.assertEqual(result["classes"], [])
def test_multiple_alerts_uses_first_title(self):
"""多个 alert → alert 取首个 title。"""
xml = (
''
''
''
''
''
''
''
''
''
).encode()
result = _make_client()._parse_aunit_result(xml)
m = result["classes"][0]["methods"][0]
self.assertEqual(m["alert"], "first")
self.assertEqual(m["line"], "9")
# ═══════════════════════════════════════════
# get_object_versions + _get_versions_link + _parse_versions
# ═══════════════════════════════════════════
_VERSIONS_LINK_XML = (
f''
f''
f''
f''
).encode()
_VERSIONS_FEED_XML = (
''
''
''
''
''
''
''
''
''
''
''
''
''
).encode()
class TestGetObjectVersions(unittest.TestCase):
def test_no_link_returns_empty(self):
client = _make_client()
with patch.object(client, "_get_versions_link", return_value=None):
self.assertEqual(client.get_object_versions("/uri"), [])
def test_relative_href_builds_full_url(self):
client = _make_client()
with patch.object(client, "_get_versions_link",
return_value="/sap/.../versions"), \
patch.object(client, "_parse_versions", return_value=[{"version": "0001"}]) as mock_parse:
client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML)
result = client.get_object_versions("/uri")
url = client.session.get.call_args.args[0]
self.assertTrue(url.startswith("https://sap.example.com/sap/"))
self.assertEqual(result, [{"version": "0001"}])
mock_parse.assert_called_once()
def test_absolute_href_used_as_is(self):
client = _make_client()
with patch.object(client, "_get_versions_link",
return_value="http://other.host/versions"):
client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML)
client.get_object_versions("/uri")
url = client.session.get.call_args.args[0]
self.assertEqual(url, "http://other.host/versions")
class TestGetVersionsLink(unittest.TestCase):
def test_finds_versions_link(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_VERSIONS_LINK_XML)
href = client._get_versions_link("/uri")
self.assertEqual(href, "/sap/bc/adt/oo/classes/ztest/versions")
def test_non_200_returns_none(self):
client = _make_client()
client.session.get.return_value = _mock_resp(404)
self.assertIsNone(client._get_versions_link("/uri"))
def test_unparseable_returns_none(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=b"<>")
self.assertIsNone(client._get_versions_link("/uri"))
def test_no_versions_link_returns_none(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
f''
).encode())
self.assertIsNone(client._get_versions_link("/uri"))
def test_empty_href_skipped(self):
"""rel 含 versions 但 href 为空 → 跳过,返回 None。"""
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f''
f''
f''
).encode())
self.assertIsNone(client._get_versions_link("/uri"))
class TestParseVersions(unittest.TestCase):
def test_unparseable_returns_empty(self):
self.assertEqual(_make_client()._parse_versions(b"<>"), [])
def test_feed_with_entries(self):
result = _make_client()._parse_versions(_VERSIONS_FEED_XML)
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["version"], "0001") # versionId 别名 → version
self.assertEqual(result[0]["author"], "DEVUSER")
self.assertEqual(result[0]["date"], "2026-06-10T14:20:00")
self.assertEqual(result[0]["versionTitle"], "Initial")
self.assertEqual(result[1]["version"], "0002")
def test_fallback_no_entries(self):
"""无 entry 但含 version/versionId 属性的元素 → 各成一行。"""
xml = (
''
''
''
''
''
).encode()
result = _make_client()._parse_versions(xml)
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["version"], "0001")
self.assertEqual(result[0]["author"], "DEV")
self.assertEqual(result[1]["version"], "0002")
# ═══════════════════════════════════════════
# get_enhancements + _parse_enhancements
# ═══════════════════════════════════════════
_ENHANCEMENTS_XML = (
''
''
''
''
''
''
''
''
''
''
''
).encode()
class TestGetEnhancements(unittest.TestCase):
def test_success_returns_parsed(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_ENHANCEMENTS_XML)
result = client.get_enhancements("/sap/bc/adt/oo/classes/zcl_test")
self.assertEqual(len(result), 2)
# body 含 obj_uri
_, kwargs = client.session.post.call_args
self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8"))
def test_http_error_propagates(self):
client = _make_client()
mock = _mock_resp(500)
mock.raise_for_status.side_effect = Exception("500")
client.session.post.return_value = mock
with self.assertRaises(Exception):
client.get_enhancements("/uri")
class TestParseEnhancements(unittest.TestCase):
def test_unparseable_returns_empty(self):
self.assertEqual(_make_client()._parse_enhancements(b"<>"), [])
def test_full_parse(self):
result = _make_client()._parse_enhancements(_ENHANCEMENTS_XML)
self.assertEqual(len(result), 2)
first = result[0]
self.assertEqual(first["name"], "Z_ENH1")
self.assertEqual(first["type"], "ENHO")
self.assertEqual(first["enhanced_name"], "ZCL_TARGET")
self.assertEqual(first["enhanced_type"], "CLAS/OC")
self.assertEqual(len(first["elements"]), 1)
el = first["elements"][0]
self.assertEqual(el["name"], "method1")
self.assertEqual(el["type"], "METHOD")
self.assertEqual(el["mode"], "overwrite")
self.assertEqual(el["replacing"], "OLD_M")
# 第二项用 enhanced 回退 + sourceCodePluginElement
second = result[1]
self.assertEqual(second["enhanced_name"], "ZIF_TARGET")
self.assertEqual(second["enhanced_type"], "INTF/OI")
self.assertEqual(second["elements"][0]["name"], "impl1")
self.assertEqual(second["elements"][0]["type"], "INCLUDE")
def test_enho_type_matching_via_type_attr(self):
"""type 属性含 ENHO 但标签名非标准 → 也能匹配。"""
xml = (
''
''
''
''
''
''
).encode()
result = _make_client()._parse_enhancements(xml)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "Z_X")
self.assertEqual(result[0]["elements"][0]["name"], "m")
def test_element_with_plain_name_type_fallback(self):
"""pluginElement 用 name/type 而非 elementName/elementType。"""
xml = (
''
''
''
''
''
''
).encode()
result = _make_client()._parse_enhancements(xml)
self.assertEqual(result[0]["elements"][0]["name"], "plain")
self.assertEqual(result[0]["elements"][0]["type"], "METHOD")
def test_enhancement_without_enhanced_object(self):
"""无 enhancedObject/enhanced → enhanced_* 为空字符串。"""
xml = (
''
''
''
''
).encode()
result = _make_client()._parse_enhancements(xml)
self.assertEqual(result[0]["enhanced_name"], "")
self.assertEqual(result[0]["enhanced_type"], "")
if __name__ == "__main__":
unittest.main(verbosity=2)