方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档, sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。 迁移(来自 sap-cli,共 104 文件): - tests/ 692 例测试(15 个文件的内联 sys.path 改指 assets/) - openspec/ SDD 规格与归档变更(42 文件) - docs/ 开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md) - .claude/ rules 副本 + settings.json(供 Claude Code) - .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md - scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁) 修复(迁移暴露的真实缺陷): - assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`, 该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。 实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令 - pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本 - pyproject urls 改指 sap-cli-skill 机制调整: - .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行, 本仓在 Gitee 不会自动跑 - pre-commit 增本地测试门禁(Gitee 上真正生效的那道) - .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志) - 大文件上限 100KB→1MB(架构图 512KB) 守卫测试 tests/unit/test_repo_guards.py(10 → 18 例): - SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5 - references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移) - VERSION == sapcli.__version__ == README 版本 - 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮) 698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。 docs/RELEASING.md 重写为单源开发流程。
1077 lines
47 KiB
Python
1077 lines
47 KiB
Python
"""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
|
||
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'<lock xmlns="{LOCK_NS}"><lockHandle>{handle}</lockHandle>'
|
||
f'<corrNr>{corr}</corrNr></lock>'
|
||
).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'<x xmlns:a="{ADTCORE_NS}" a:name="ZTEST"/>')
|
||
self.assertEqual(_attr_local(el, "name"), "ZTEST")
|
||
|
||
def test_attr_local_plain_name(self):
|
||
"""无命名空间前缀的属性也能匹配。"""
|
||
el = ET.fromstring("<x name='ZTEST'/>")
|
||
self.assertEqual(_attr_local(el, "name"), "ZTEST")
|
||
|
||
def test_attr_local_missing_returns_empty(self):
|
||
el = ET.fromstring("<x other='1'/>")
|
||
self.assertEqual(_attr_local(el, "name"), "")
|
||
|
||
def test_find_local_found(self):
|
||
root = ET.fromstring(
|
||
f'<r xmlns:a="{ADTCORE_NS}"><a:summary tests="3"/></r>'
|
||
)
|
||
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("<r><other/></r>")
|
||
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 的 <program:abapProgram>,否则 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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<abap xmlns="http://www.sap.com/abapxml/types">'
|
||
'<domain xmlns:ddic="http://www.sap.com/abapxml/types"/>'
|
||
'</abap>'
|
||
)
|
||
|
||
|
||
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", "<ddl/>")
|
||
# 先 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", "<ddl/>")
|
||
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", "<ddl/>")
|
||
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", "<xml/>", 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", "<xml/>"))
|
||
_, 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", "<xml/>")
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# _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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}"'
|
||
f' adtcore:version="inactive"/>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}"'
|
||
f' adtcore:version="custom_v2"/>'
|
||
).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"<<not xml>>")
|
||
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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}"/>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}"/>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<pkg:package xmlns:pkg="http://www.sap.com/adt/packages"'
|
||
f' xmlns:adtcore="{ADTCORE_NS}"'
|
||
f' adtcore:name="Z_TEST" adtcore:description="Test pkg"'
|
||
f' adtcore:owner="DEVUSER">'
|
||
f'<adtcore:packageRef adtcore:name="Z_PARENT"/>'
|
||
f'</pkg:package>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<pkg:package xmlns:pkg="http://www.sap.com/adt/packages"'
|
||
f' xmlns:adtcore="{ADTCORE_NS}"'
|
||
f' adtcore:name="Z_TEST"/>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<chk:checks xmlns:chk="http://www.sap.com/adt/checklist">'
|
||
f'<chk:checkList/></chk:checks>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<chk:checks xmlns:chk="http://www.sap.com/adt/checklist">'
|
||
f'<chk:msg severity="1" line="5" message="critical"/>'
|
||
f'</chk:checks>'
|
||
).encode())
|
||
ok, findings = client.atc_check("ZTEST", "/uri")
|
||
self.assertFalse(ok)
|
||
self.assertEqual(len(findings), 1)
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# get_table_fields
|
||
# ═══════════════════════════════════════════
|
||
|
||
_TABLE_FIELDS_XML = (
|
||
f'<?xml version="1.0"?>'
|
||
f'<dp:preview xmlns:dp="{DP_NS}">'
|
||
f'<dp:columns>'
|
||
f'<dp:metadata dp:name="MANDT" dp:type="CHAR" dp:length="3"'
|
||
f' dp:description="Client" dp:keyAttribute="true"/>'
|
||
f'<dp:metadata dp:name="MATNR" dp:type="CHAR" dp:length="18"'
|
||
f' dp:description="Material"/>'
|
||
f'</dp:columns>'
|
||
f'</dp:preview>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<dp:preview xmlns:dp="{DP_NS}">'
|
||
f'<dp:columns>'
|
||
f'<dp:metadata dp:name="MANDT"/>'
|
||
f'<dp:dataSet><dp:data>100</dp:data><dp:data>200</dp:data></dp:dataSet>'
|
||
f'</dp:columns>'
|
||
f'<dp:columns>'
|
||
f'<dp:metadata dp:name="MATNR"/>'
|
||
f'<dp:dataSet><dp:data>A001</dp:data><dp:data>A002</dp:data></dp:dataSet>'
|
||
f'</dp:columns>'
|
||
f'<dp:totalRows>2</dp:totalRows>'
|
||
f'<dp:queryExecutionTime>0.012</dp:queryExecutionTime>'
|
||
f'</dp:preview>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<dp:preview xmlns:dp="{DP_NS}">'
|
||
f'<dp:columns>'
|
||
f'<dp:metadata dp:name="MANDT"/>'
|
||
f'<dp:dataSet><dp:data>100</dp:data></dp:dataSet>'
|
||
f'</dp:columns>'
|
||
f'<dp:columns><dp:metadata dp:name="EMPTY"/></dp:columns>'
|
||
f'</dp:preview>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<dp:preview xmlns:dp="{DP_NS}">'
|
||
f'<dp:totalRows>0</dp:totalRows>'
|
||
f'</dp:preview>'
|
||
).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)
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<aunit:runResult xmlns:aunit="http://www.sap.com/adt/aunit"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<summary tests="3" failures="1" errors="0" skipped="0" duration="450"/>'
|
||
'<program adtcore:name="ZTEST">'
|
||
'<testClasses>'
|
||
'<testClass adtcore:name="ZCL_TEST">'
|
||
'<testMethods>'
|
||
'<testMethod adtcore:name="test_foo" duration="0.45">'
|
||
'<alert kind="t" title="assertion error">'
|
||
'<stackInfo><info line="42"/></stackInfo>'
|
||
'</alert>'
|
||
'</testMethod>'
|
||
'<testMethod adtcore:name="test_bar" duration="0.10"/>'
|
||
'</testMethods>'
|
||
'</testClass>'
|
||
'</testClasses>'
|
||
'</program>'
|
||
'</aunit:runResult>'
|
||
).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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<aunit:runResult xmlns:aunit="http://www.sap.com/adt/aunit"/>'
|
||
).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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<aunit:runResult xmlns:aunit="http://www.sap.com/adt/aunit"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<testClass adtcore:name="ZCL">'
|
||
'<testMethod adtcore:name="m1" duration="0.1">'
|
||
'<alert title="first"><stackInfo/></alert>'
|
||
'<alert title="second"><stackInfo><info line="9"/></stackInfo></alert>'
|
||
'</testMethod>'
|
||
'</testClass>'
|
||
'</aunit:runResult>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}">'
|
||
f'<adtcore:link rel="http://www.sap.com/adt/relations/versions"'
|
||
f' href="/sap/bc/adt/oo/classes/ztest/versions"/>'
|
||
f'</adtcore:object>'
|
||
).encode()
|
||
|
||
_VERSIONS_FEED_XML = (
|
||
'<?xml version="1.0"?>'
|
||
'<atom:feed xmlns:atom="http://www.w3.org/2005/Atom"'
|
||
' xmlns:app="http://www.w3.org/2007/app"'
|
||
' xmlns:d="http://www.sap.com/adt/versions">'
|
||
'<atom:entry>'
|
||
'<app:control>'
|
||
'<d:version d:versionId="0001" d:author="DEVUSER"'
|
||
' d:date="2026-06-10T14:20:00" d:versionTitle="Initial"/>'
|
||
'</app:control>'
|
||
'</atom:entry>'
|
||
'<atom:entry>'
|
||
'<app:control>'
|
||
'<d:version d:versionId="0002" d:author="DEVUSER"'
|
||
' d:date="2026-06-11T10:00:00" d:versionTitle="Update"/>'
|
||
'</app:control>'
|
||
'</atom:entry>'
|
||
'</atom:feed>'
|
||
).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"<<bad>>")
|
||
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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}">'
|
||
f'<adtcore:link rel="other" href="/x"/></adtcore:object>'
|
||
).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'<?xml version="1.0"?>'
|
||
f'<adtcore:object xmlns:adtcore="{ADTCORE_NS}">'
|
||
f'<adtcore:link rel="versions" href=""/></adtcore:object>'
|
||
).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"<<bad>>"), [])
|
||
|
||
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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<root xmlns:d="http://x">'
|
||
'<d:version d:version="0001" d:author="DEV"/>'
|
||
'<d:version d:versionId="0002" d:author="DEV2"/>'
|
||
'</root>'
|
||
).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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<enh:enhancements xmlns:enh="http://www.sap.com/adt/enhancements"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<enh:enhancement adtcore:name="Z_ENH1" adtcore:type="ENHO">'
|
||
'<enh:enhancedObject adtcore:name="ZCL_TARGET" adtcore:type="CLAS/OC"/>'
|
||
'<enh:pluginElement adtcore:elementName="method1" adtcore:elementType="METHOD"'
|
||
' mode="overwrite" replacing="OLD_M"/>'
|
||
'</enh:enhancement>'
|
||
'<enh:enhancementImplementation adtcore:name="Z_ENH2" adtcore:type="ENHO">'
|
||
'<enh:enhanced adtcore:name="ZIF_TARGET" adtcore:type="INTF/OI"/>'
|
||
'<enh:sourceCodePluginElement adtcore:elementName="impl1" adtcore:elementType="INCLUDE"/>'
|
||
'</enh:enhancementImplementation>'
|
||
'</enh:enhancements>'
|
||
).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"<<bad>>"), [])
|
||
|
||
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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<r xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<custom:impl xmlns:custom="http://x" adtcore:name="Z_X" adtcore:type="ENHO">'
|
||
'<adtcore:element adtcore:name="m" adtcore:type="METHOD"/>'
|
||
'</custom:impl>'
|
||
'</r>'
|
||
).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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<r xmlns:enh="http://www.sap.com/adt/enhancements"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<enh:enhancement adtcore:name="Z_E" adtcore:type="ENHO">'
|
||
'<enh:element adtcore:name="plain" adtcore:type="METHOD"/>'
|
||
'</enh:enhancement>'
|
||
'</r>'
|
||
).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 = (
|
||
'<?xml version="1.0"?>'
|
||
'<r xmlns:enh="http://www.sap.com/adt/enhancements"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<enh:enhancement adtcore:name="Z_E" adtcore:type="ENHO"/>'
|
||
'</r>'
|
||
).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)
|