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