Files
sap-cli-skill/tests/unit/test_ddic.py
T
吴让宇 c5905a5b1e refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 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 重写为单源开发流程。
2026-09-11 00:40:15 +08:00

1077 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)