Files
sap-cli-skill/tests/unit/test_client.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

1177 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.py 单元测试 — 30 个 ADT API 方法
运行: python tests/unit/test_client.py
"""
from __future__ import annotations
import os
import sys
import unittest
import xml.etree.ElementTree as ET
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
from sapcli.client import ADTClient
from sapcli.exceptions import (
LoginError, SapCliError, CreateError, DeleteError,
ActivationError, SyntaxCheckError, LockError,
)
# ADT XML 命名空间
TM_NS = "http://www.sap.com/cts/adt/tm"
ADTCORE_NS = "http://www.sap.com/adt/core"
CHK_NS = "http://www.sap.com/adt/checklist"
LOCK_NS = "http://www.sap.com/adt/lock"
def _make_client() -> ADTClient:
"""创建 mock 好的 ADTClient。"""
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
# ═══════════════════════════════════════════
# 基础方法
# ═══════════════════════════════════════════
class TestHeaders(unittest.TestCase):
def test_default(self):
h = _make_client()._headers()
self.assertEqual(h["content-type"], "application/xml")
self.assertEqual(h["x-csrf-token"], "test-csrf-token")
def test_custom(self):
h = _make_client()._headers("text/plain")
self.assertEqual(h["content-type"], "text/plain")
class TestContextManager(unittest.TestCase):
def test_enter_calls_login(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "TOK"})
result = client.__enter__()
self.assertIs(result, client)
def test_exit_closes_session(self):
client = _make_client()
client.__exit__(None, None, None)
client.session.close.assert_called_once()
# ═══════════════════════════════════════════
# 认证
# ═══════════════════════════════════════════
class TestLogin(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "NEW_TOK"})
self.assertTrue(client.login())
self.assertEqual(client.csrf_token, "NEW_TOK")
def test_failure(self):
client = _make_client()
client.session.get.return_value = _mock_resp(401)
with self.assertRaises(LoginError):
client.login()
# ═══════════════════════════════════════════
# 对象存在检查
# ═══════════════════════════════════════════
class TestObjectExists(unittest.TestCase):
def test_exists(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200)
self.assertTrue(client.object_exists("/uri"))
def test_not_exists(self):
client = _make_client()
client.session.get.return_value = _mock_resp(404)
self.assertFalse(client.object_exists("/uri"))
# ═══════════════════════════════════════════
# 源码读取
# ═══════════════════════════════════════════
class TestGetSource(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, text="REPORT ztest.")
self.assertEqual(client.get_source("/src"), "REPORT ztest.")
def test_not_found(self):
client = _make_client()
mock = _mock_resp(404)
mock.raise_for_status.side_effect = Exception("404")
client.session.get.return_value = mock
with self.assertRaises(Exception):
client.get_source("/nonexistent")
# ═══════════════════════════════════════════
# 锁管理
# ═══════════════════════════════════════════
class TestLock(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}">'
f'<lockHandle>lh_123</lockHandle>'
f'<corrNr>DEVK001</corrNr></lock>'
).encode())
handle, corr = client.lock("/uri")
self.assertEqual(handle, "lh_123")
self.assertEqual(corr, "DEVK001")
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(403)
with self.assertRaises(LockError):
client.lock("/uri")
def test_no_handle(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"></lock>'
).encode())
with self.assertRaises(LockError):
client.lock("/uri")
class TestUnlock(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200)
self.assertTrue(client.unlock("/uri", "lh"))
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500)
self.assertFalse(client.unlock("/uri", "lh"))
# ═══════════════════════════════════════════
# 源码写入
# ═══════════════════════════════════════════
class TestSetSource(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.put.return_value = _mock_resp(200)
self.assertTrue(client.set_source("/src", "code", "lh"))
def test_with_corr_nr(self):
client = _make_client()
client.session.put.return_value = _mock_resp(200)
self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001"))
# ═══════════════════════════════════════════
# 传输请求
# ═══════════════════════════════════════════
_TRANSPORT_XML = (
f'<?xml version="1.0"?>'
f'<tm:transportorganizer xmlns:tm="{TM_NS}">'
f'<tm:request tm:number="DEVK001" tm:status="D" tm:description="Test"/>'
f'</tm:transportorganizer>'
).encode()
_TRANSPORT_EMPTY = (
f'<?xml version="1.0"?>'
f'<tm:transportorganizer xmlns:tm="{TM_NS}"/>'
).encode()
# create_transport_request 返回的 XML(带 tm:number 属性)
_CREATE_TRANSPORT_RESP = (
f'<?xml version="1.0"?>'
f'<tm:transportorganizer xmlns:tm="{TM_NS}">'
f'<tm:request tm:number="DEVK002" tm:status="D"/>'
f'</tm:transportorganizer>'
).encode()
class TestGetTransportRequest(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML)
self.assertEqual(client.get_transport_request(), "DEVK001")
def test_none(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY)
self.assertIsNone(client.get_transport_request())
class TestListTransportRequests(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML)
self.assertIsInstance(client.list_transport_requests(), list)
def test_empty(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY)
self.assertEqual(client.list_transport_requests(), [])
class TestCreateTransportRequest(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201, content=_CREATE_TRANSPORT_RESP)
result = client.create_transport_request("My request")
self.assertEqual(result, "DEVK002")
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(CreateError):
client.create_transport_request("Fail")
# ═══════════════════════════════════════════
# 激活 — 解析 type="E" 属性
# ═══════════════════════════════════════════
class TestActivate(unittest.TestCase):
def test_success_empty(self):
"""空响应视为成功。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="", content=b"")
ok, msgs = client.activate("ztest", "/uri")
self.assertTrue(ok)
def test_failure_with_error_type(self):
"""包含 type="E" 属性的元素视为失败。"""
client = _make_client()
xml_body = (
f'<?xml version="1.0"?>'
f'<adtcore:activation xmlns:adtcore="{ADTCORE_NS}">'
f'<adtcore:objectReference type="E" line="1" href="">'
f'<adtcore:txt>Activation error</adtcore:txt>'
f'</adtcore:objectReference>'
f'</adtcore:activation>'
)
client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode())
ok, msgs = client.activate("ztest", "/uri")
self.assertFalse(ok)
def test_http_error(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500)
with self.assertRaises(ActivationError):
client.activate("ztest", "/uri")
# ── NW 7.40 double-activate workaround(空响应验证路径)──────────
def _verify_xml(self, version):
"""构造 verify GET 响应的 XMLbytes),adtcore:version 指定。"""
return (
f'<?xml version="1.0" encoding="UTF-8"?>'
f'<abapsource:abapSource xmlns:abapsource="http://www.sap.com/adt/abapsource"'
f' xmlns:adtcore="{ADTCORE_NS}"'
f' adtcore:version="{version}">'
f'</abapsource:abapSource>'
).encode()
def test_empty_response_already_active(self):
"""空响应 + verify 显示 active → 成功,无需重试(post 仅 1 次)。"""
client = _make_client()
# 激活 POST → 200 空响应
client.session.post.return_value = _mock_resp(200, text="", content=b"")
# verify GET → 200, version="active"
client.session.get.return_value = _mock_resp(200, content=self._verify_xml("active"))
ok, msgs = client.activate("ztest", "/uri")
self.assertTrue(ok)
self.assertEqual(msgs, [])
# 区分真/假实现:已 active 时不应重试激活
self.assertEqual(client.session.post.call_count, 1)
def test_double_activate_success(self):
"""空响应 + inactive → 重试一次 → activedouble-activate 成功路径)。"""
client = _make_client()
# 激活 POST + 重试 POST → 均 200 空
client.session.post.side_effect = [
_mock_resp(200, text="", content=b""),
_mock_resp(200, text="", content=b""),
]
# verify GET → inactive;再 verify GET → active
client.session.get.side_effect = [
_mock_resp(200, content=self._verify_xml("inactive")),
_mock_resp(200, content=self._verify_xml("active")),
]
ok, msgs = client.activate("ztest", "/uri")
self.assertTrue(ok)
self.assertEqual(msgs, [])
# 区分真/假实现:确实重试了,post 被调用 2 次
self.assertEqual(client.session.post.call_count, 2)
# 两次 verify GET
self.assertEqual(client.session.get.call_count, 2)
def test_double_activate_still_inactive(self):
"""重试后仍 inactive → 失败并返回 SE09 提示(post 调用 2 次)。"""
client = _make_client()
client.session.post.side_effect = [
_mock_resp(200, text="", content=b""),
_mock_resp(200, text="", content=b""),
]
# 两次 verify GET 都返回 inactive
client.session.get.side_effect = [
_mock_resp(200, content=self._verify_xml("inactive")),
_mock_resp(200, content=self._verify_xml("inactive")),
]
ok, msgs = client.activate("ztest", "/uri")
self.assertFalse(ok)
# 重试过一次,共 2 次 POST
self.assertEqual(client.session.post.call_count, 2)
# 返回的消息含 SE09 或 inactive 提示
msg_text = " ".join(m.get("text", "") for m in msgs)
self.assertTrue("SE09" in msg_text or "inactive" in msg_text)
# ═══════════════════════════════════════════
# 语法检查 — 同样解析 type="E"
# ═══════════════════════════════════════════
class TestSyntaxCheck(unittest.TestCase):
def test_clean(self):
"""空响应视为通过。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="", content=b"")
ok, msgs = client.syntax_check("ztest", "/uri")
self.assertTrue(ok)
def test_with_errors(self):
client = _make_client()
xml_body = (
f'<?xml version="1.0"?>'
f'<chk:checks xmlns:chk="{CHK_NS}">'
f'<chk:msg type="E" line="1" href="">'
f'<chk:txt>Syntax error at line 1</chk:txt>'
f'</chk:msg>'
f'</chk:checks>'
)
client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode())
ok, msgs = client.syntax_check("ztest", "/uri")
self.assertFalse(ok)
def test_http_error(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500)
with self.assertRaises(SyntaxCheckError):
client.syntax_check("ztest", "/uri")
# ═══════════════════════════════════════════
# 删除对象(内部调 lock
# ═══════════════════════════════════════════
class TestDeleteObject(unittest.TestCase):
def _mock_lock_ok(self, client):
client.session.post.return_value = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"><lockHandle>lh_del</lockHandle></lock>'
).encode())
def test_success_200(self):
client = _make_client()
self._mock_lock_ok(client)
client.session.delete.return_value = _mock_resp(200)
ok, msg = client.delete_object("/uri")
self.assertTrue(ok)
def test_success_204(self):
client = _make_client()
self._mock_lock_ok(client)
client.session.delete.return_value = _mock_resp(204)
ok, msg = client.delete_object("/uri")
self.assertTrue(ok)
def test_failure(self):
client = _make_client()
self._mock_lock_ok(client)
client.session.delete.return_value = _mock_resp(500, text="Cannot delete")
with self.assertRaises(DeleteError):
client.delete_object("/uri")
def test_ddic_lock_uses_special_accept_header(self):
"""delete 调用 lock 时传 DDIC lock 专用 Accept 头(缺陷4)。
DDIC 对象(domain/dataelement/table/structure)的 lock 端点对默认
Accept: */* 返回 HTTP 406。参考 abap-adt-api objectcontents.ts 的 lock
实现,需用 application/*,application/vnd.sap.as+xml;charset=UTF-8;
dataname=com.sap.adt.lock.result。
"""
client = _make_client()
self._mock_lock_ok(client)
client.session.delete.return_value = _mock_resp(204)
client.delete_object("/uri")
# lock 的 POST 请求必须携带 DDIC lock 专用 Accept 头。
# 注意:delete 成功后会再发一次 unlock 的 POST,故不能用 call_args
# (那是最后一次调用 = unlock),须从调用列表中挑出 _action=LOCK 的请求。
lock_calls = [
kw for _, kw in client.session.post.call_args_list
if kw.get("params", {}).get("_action") == "LOCK"
]
self.assertEqual(len(lock_calls), 1, "delete 应恰好发起一次 LOCK 请求")
accept = lock_calls[0]["headers"]["Accept"]
self.assertIn("com.sap.adt.lock.result", accept)
self.assertNotEqual(accept, "*/*")
# ═══════════════════════════════════════════
# 创建对象
# ═══════════════════════════════════════════
class TestCreateObject(unittest.TestCase):
def test_report_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201, headers={
"Location": "/sap/bc/adt/programs/programs/ztest"
})
obj_uri, src_uri = client.create_object("report", "ZTEST", "Test report")
self.assertIn("ztest", obj_uri)
def test_class_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201, headers={
"Location": "/sap/bc/adt/oo/classes/zcl_test"
})
obj_uri, src_uri = client.create_object("class", "ZCL_TEST", "Test class")
self.assertIn("zcl_test", obj_uri)
def test_with_source(self):
"""有 source → lock → set_source → unlock → activate。"""
client = _make_client()
lock_resp = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"><lockHandle>lh</lockHandle></lock>'
).encode())
create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/programs/programs/ztest"})
unlock_resp = _mock_resp(200)
activate_resp = _mock_resp(200, text="", content=b"")
client.session.post.side_effect = [create_resp, lock_resp, unlock_resp, activate_resp]
client.session.put.return_value = _mock_resp(200)
obj_uri, src_uri = client.create_object("report", "ZTEST", "Test", source="REPORT ztest.")
self.assertIn("ztest", obj_uri)
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(409, text="Already exists")
with self.assertRaises(CreateError):
client.create_object("report", "ZTEST", "Test")
class TestBuildCreateBody(unittest.TestCase):
def test_report(self):
body = _make_client()._build_create_body("report", "ZTEST", "Desc")
self.assertIn("ZTEST", body)
def test_class(self):
body = _make_client()._build_create_body("class", "ZCL_TEST", "Desc")
self.assertIn("ZCL_TEST", body)
def test_unsupported(self):
with self.assertRaises(ValueError):
_make_client()._build_create_body("unknown", "ZTEST", "Desc")
# ═══════════════════════════════════════════
# DDIC 对象 — definition_body 是 XML 字符串
# ═══════════════════════════════════════════
_DDIC_DOMAIN_XML = (
f'<?xml version="1.0" encoding="UTF-8"?>'
f'<abap xmlns="http://www.sap.com/abapxml/types">'
f'<domain ddic:version="1.0" xmlns:ddic="http://www.sap.com/abapxml/types">'
f'</domain></abap>'
)
class TestCreateDDICObject(unittest.TestCase):
def test_domain_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201)
# mock activatecreate_ddic_object 内部调用)
client.session.post.side_effect = [
_mock_resp(201), # create POST
_mock_resp(200, text="", content=b""), # activate POST
]
obj_uri, src_uri = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML)
self.assertIn("ztest_dom", obj_uri)
def test_table_success(self):
"""table 类型先 POST 创建实体,再 PUT 写入 DDL(缺陷3 修复后)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(201)
# mock _put_ddl_source
with patch.object(client, "_put_ddl_source") as mock_ddl, \
patch.object(client, "activate", return_value=(True, [])):
obj_uri, src_uri = client.create_ddic_object(
"table", "ZTEST_TAB", "<ddl/>"
)
# 先 POST 创建实体,再 PUT DDL
self.assertTrue(client.session.post.called)
mock_ddl.assert_called_once()
self.assertIn("ztest_tab", obj_uri)
def test_create_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(CreateError):
client.create_ddic_object("domain", "ZTEST", _DDIC_DOMAIN_XML)
# ═══════════════════════════════════════════
# 对象状态
# ═══════════════════════════════════════════
class TestGetObjectStatus(unittest.TestCase):
def test_active(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:packageRef adtcore:name="$TMP"/>'
f'</adtcore:object>'
).encode())
self.assertTrue(client.get_object_status("/uri")["exists"])
def test_not_exists(self):
client = _make_client()
client.session.get.return_value = _mock_resp(404)
self.assertFalse(client.get_object_status("/uri")["exists"])
# ═══════════════════════════════════════════
# 函数组
# ═══════════════════════════════════════════
class TestFunctionGroup(unittest.TestCase):
def test_exists_true(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200)
self.assertTrue(client.function_group_exists("ZGROUP"))
def test_exists_false(self):
client = _make_client()
client.session.get.return_value = _mock_resp(404)
self.assertFalse(client.function_group_exists("ZGROUP"))
def test_create_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201)
self.assertTrue(client.create_function_group("ZGROUP"))
def test_create_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(CreateError):
client.create_function_group("ZGROUP")
# ═══════════════════════════════════════════
# 搜索浏览
# ═══════════════════════════════════════════
_LIST_TWO = (
f'<?xml version="1.0"?>'
f'<adtcore:objectReferences xmlns:adtcore="{ADTCORE_NS}">'
f'<adtcore:objectReference adtcore:name="Z_TEST1" adtcore:type="PROG/P">'
f'<adtcore:packageRef adtcore:name="$TMP"/></adtcore:objectReference>'
f'<adtcore:objectReference adtcore:name="Z_TEST2" adtcore:type="PROG/P">'
f'<adtcore:packageRef adtcore:name="$TMP"/></adtcore:objectReference>'
f'</adtcore:objectReferences>'
).encode()
_LIST_EMPTY = (
f'<?xml version="1.0"?>'
f'<adtcore:objectReferences xmlns:adtcore="{ADTCORE_NS}"/>'
).encode()
class TestListObjects(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_LIST_TWO)
result = client.list_objects(obj_type="report")
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["name"], "Z_TEST1")
def test_empty(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_LIST_EMPTY)
self.assertEqual(client.list_objects(), [])
class TestWhereUsed(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<adtcore:objectReferences xmlns:adtcore="{ADTCORE_NS}">'
f'<adtcore:objectReference adtcore:name="Z_USER" adtcore:type="PROG/P"/>'
f'</adtcore:objectReferences>'
).encode())
result = client.where_used("Z_TEST", "/uri", "PROG/P")
self.assertEqual(len(result), 1)
def test_empty(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY)
self.assertEqual(client.where_used("Z", "/uri"), [])
class TestSearchCode(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<adtcore:objectReferences xmlns:adtcore="{ADTCORE_NS}">'
f'<adtcore:objectReference adtcore:name="Z_RESULT" adtcore:type="PROG/P"/>'
f'</adtcore:objectReferences>'
).encode())
self.assertEqual(len(client.search_code("SELECT", obj_type="report")), 1)
def test_empty(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY)
self.assertEqual(client.search_code("NONEXISTENT"), [])
class TestReadSourceForDiff(unittest.TestCase):
def test_success(self):
client = _make_client()
with patch.object(client, "get_source", return_value="REPORT ztest."):
self.assertEqual(client.read_source_for_diff("ZTEST", "report"), "REPORT ztest.")
def test_no_src_uri_raises(self):
client = _make_client()
with self.assertRaises(SapCliError):
client.read_source_for_diff("ZTEST", "functiongroup")
# ═══════════════════════════════════════════
# CDS
# ═══════════════════════════════════════════
class TestGetCdsSource(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, text="define view Z_TEST as select from mara { mara.matnr };")
self.assertIn("define view", client.get_cds_source("Z_TEST"))
def test_not_found(self):
client = _make_client()
mock = _mock_resp(404)
mock.raise_for_status.side_effect = Exception("404")
client.session.get.return_value = mock
with self.assertRaises(Exception):
client.get_cds_source("NONEXISTENT")
class TestCreateCDS(unittest.TestCase):
def test_success(self):
client = _make_client()
lock_resp = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"><lockHandle>lh_cds</lockHandle></lock>'
).encode())
create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/dds/ddl/sources/z_test"})
unlock_resp = _mock_resp(200)
client.session.post.side_effect = [create_resp, lock_resp, unlock_resp]
client.session.put.return_value = _mock_resp(200)
obj_uri, src_uri = client.create_cds("Z_TEST", "Test", "define view Z_TEST as select from mara { mara.matnr };")
self.assertIn("z_test", obj_uri)
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(CreateError):
client.create_cds("Z_TEST", "Test", "ddl source")
# ═══════════════════════════════════════════
# 包管理
# ═══════════════════════════════════════════
class TestPackage(unittest.TestCase):
def test_create_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(201)
self.assertTrue(client.create_package("Z_TEST_PKG"))
def test_create_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(CreateError):
client.create_package("Z_TEST_PKG")
def test_get_info_success(self):
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" pkg:name="Z_TEST">'
f'<pkg:description>Test</pkg:description></pkg:package>'
).encode())
self.assertIsInstance(client.get_package_info("Z_TEST"), dict)
def test_get_info_not_found(self):
client = _make_client()
mock = _mock_resp(404)
mock.raise_for_status.side_effect = Exception("404")
client.session.get.return_value = mock
with self.assertRaises(Exception):
client.get_package_info("NONEXISTENT")
# ═══════════════════════════════════════════
# 传输管理
# ═══════════════════════════════════════════
class TestTransportInfo(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<tm:transportorganizer xmlns:tm="{TM_NS}">'
f'<tm:request tm:number="DEVK001" tm:description="Test" tm:status="D" tm:owner="TESTUSER"/>'
f'</tm:transportorganizer>'
).encode())
result = client.transport_info("DEVK001")
self.assertEqual(result["number"], "DEVK001")
def test_not_found(self):
client = _make_client()
mock = _mock_resp(404)
mock.raise_for_status.side_effect = Exception("404")
client.session.get.return_value = mock
with self.assertRaises(Exception):
client.transport_info("NONEXISTENT")
class TestTransportRelease(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200)
self.assertTrue(client.transport_release("DEVK001"))
def test_failure(self):
client = _make_client()
client.session.post.return_value = _mock_resp(500, text="Error")
with self.assertRaises(SapCliError):
client.transport_release("DEVK001")
class TestTransportObjects(unittest.TestCase):
def test_found(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<cts:transport xmlns:cts="http://www.sap.com/adt/cts">'
f'<cts:objects><cts:object cts:pgmid="R3TR" cts:object="PROG" cts:objName="Z_TEST"/></cts:objects>'
f'</cts:transport>'
).encode())
self.assertIsInstance(client.transport_objects("DEVK001"), list)
def test_empty(self):
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<cts:transport xmlns:cts="http://www.sap.com/adt/cts"><cts:objects/></cts:transport>'
).encode())
self.assertEqual(client.transport_objects("DEVK001"), [])
# ═══════════════════════════════════════════
# 代码质量
# ═══════════════════════════════════════════
class TestATCCheck(unittest.TestCase):
def test_clean(self):
client = _make_client()
xml_body = (
f'<?xml version="1.0"?>'
f'<chk:checks xmlns:chk="{CHK_NS}"><chk:checkList/></chk:checks>'
)
client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode())
ok, findings = client.atc_check("ZTEST", "/uri")
self.assertTrue(ok)
def test_with_findings(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<?xml version="1.0"?>'
f'<chk:checks xmlns:chk="{CHK_NS}">'
f'<chk:msg type="E" line="1" href="">'
f'<chk:txt>Unused variable</chk:txt>'
f'</chk:msg>'
f'</chk:checks>'
).encode())
ok, findings = client.atc_check("ZTEST", "/uri")
self.assertFalse(ok)
class TestPrettyPrint(unittest.TestCase):
def test_success(self):
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="FORMATTED CODE")
self.assertEqual(client.pretty_print("raw code"), "FORMATTED CODE")
def test_error(self):
"""pretty_print 用 raise_for_status() 抛 HTTPError。"""
client = _make_client()
mock = _mock_resp(500, text="Error")
mock.raise_for_status.side_effect = Exception("500 Server Error")
client.session.post.return_value = mock
with self.assertRaises(Exception):
client.pretty_print("raw code")
# ═══════════════════════════════════════════
# _build_create_body 各类型
# ═══════════════════════════════════════════
class TestBuildCreateBodyTypes(unittest.TestCase):
def _check(self, obj_type, name):
body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}")
self.assertIn(name, body)
def test_report(self): self._check("report", "ZTEST")
def test_class(self): self._check("class", "ZCL_TEST")
def test_interface(self): self._check("interface", "ZIF_TEST")
def test_domain(self): self._check("domain", "ZTEST_DOM")
def test_dataelement(self): self._check("dataelement", "ZTEST_DE")
def test_table(self): self._check("table", "ZTEST_TAB")
def test_structure(self): self._check("structure", "ZTEST_STR")
def test_tabletype(self): self._check("tabletype", "ZTEST_TT")
def test_unsupported(self):
with self.assertRaises(ValueError):
_make_client()._build_create_body("bogus_type", "ZTEST", "Test")
# ═══════════════════════════════════════════
# set_source 错误重试路径(行 59-69
# ═══════════════════════════════════════════
class TestSetSourceRetry(unittest.TestCase):
def _error_body(self, corrnr):
return f'<error><entry key="corrNr">{corrnr}</entry></error>'
def test_retry_with_extracted_corrnr_succeeds(self):
"""首次 400 + 错误体含不同 corrNr → 用该 corrNr 重试成功。"""
client = _make_client()
client.session.put.side_effect = [
_mock_resp(400, text=self._error_body("DEVK999")),
_mock_resp(200),
]
self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001"))
self.assertEqual(client.session.put.call_count, 2)
def test_retry_fails_raises(self):
"""重试后仍 400 → raise_for_status。"""
client = _make_client()
first = _mock_resp(400, text=self._error_body("DEVK999"))
retry = _mock_resp(400)
retry.raise_for_status.side_effect = Exception("still 400")
client.session.put.side_effect = [first, retry]
with self.assertRaises(Exception):
client.set_source("/src", "code", "lh", corr_nr="DEVK001")
def test_no_corrnr_in_error_raises(self):
"""错误体无 corrNr → 不重试,直接 raise。"""
client = _make_client()
resp = _mock_resp(400, text="<error>boom</error>")
resp.raise_for_status.side_effect = Exception("400")
client.session.put.return_value = resp
with self.assertRaises(Exception):
client.set_source("/src", "code", "lh")
self.assertEqual(client.session.put.call_count, 1)
# ═══════════════════════════════════════════
# lock 高级路径(行 94, 101-107
# ═══════════════════════════════════════════
class TestLockAdvanced(unittest.TestCase):
def test_lock_with_corr_nr(self):
"""传 corr_nr → 写入 params 并原样返回(不提取)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"><lockHandle>lh_123</lockHandle></lock>'
).encode())
handle, corr = client.lock("/uri", corr_nr="DEVK001")
self.assertEqual(handle, "lh_123")
self.assertEqual(corr, "DEVK001")
def test_lock_500_retry_with_existing_corrnr(self):
"""首次 500 + 错误体含 corrNr → 用该 corrNr 重试成功。"""
client = _make_client()
error_body = '<error><entry key="corrNr">DEVK999</entry></error>'
client.session.post.side_effect = [
_mock_resp(500, text=error_body),
_mock_resp(200, content=(
f'<lock xmlns="{LOCK_NS}"><lockHandle>lh</lockHandle></lock>'
).encode()),
]
handle, corr = client.lock("/uri", corr_nr="DEVK001")
self.assertEqual(handle, "lh")
self.assertEqual(corr, "DEVK999")
self.assertEqual(client.session.post.call_count, 2)
# ═══════════════════════════════════════════
# _extract_locked_corrnr(行 135-149
# ═══════════════════════════════════════════
class TestExtractLockedCorrnr(unittest.TestCase):
def test_key_corrnr(self):
client = _make_client()
body = '<root><entry key="corrNr">DEVK001</entry></root>'
self.assertEqual(client._extract_locked_corrnr(body), "DEVK001")
def test_star_skipped_no_message(self):
client = _make_client()
body = '<root><entry key="corrNr">*</entry></root>'
self.assertIsNone(client._extract_locked_corrnr(body))
def test_message_branch(self):
client = _make_client()
body = '<root><message>Object is locked in request DEVK888</message></root>'
self.assertEqual(client._extract_locked_corrnr(body), "DEVK888")
def test_invalid_xml(self):
client = _make_client()
self.assertIsNone(client._extract_locked_corrnr("not valid xml <<"))
# ═══════════════════════════════════════════
# _extract_corrnr_from_lock_response(行 166, 172-175, 183-185
# ═══════════════════════════════════════════
class TestExtractCorrnrFromLockResponse(unittest.TestCase):
def _extract(self, xml):
return _make_client()._extract_corrnr_from_lock_response(ET.fromstring(xml))
def test_strategy1_cornn_tag(self):
self.assertEqual(
self._extract('<lock><CORRN>DEVK001</CORRN></lock>'),
"DEVK001",
)
def test_strategy1_star_skipped(self):
self.assertIsNone(
self._extract('<lock><CORRN>*</CORRN></lock>'),
)
def test_strategy2_attr(self):
self.assertEqual(
self._extract('<lock><meta myCorrNr="DEVK003"/></lock>'),
"DEVK003",
)
def test_strategy2_tagname(self):
self.assertEqual(
self._extract('<lock><myCorrField>DEVK004</myCorrField></lock>'),
"DEVK004",
)
def test_strategy3_property_key(self):
self.assertEqual(
self._extract('<lock><property key="corrNr">DEVK005</property></lock>'),
"DEVK005",
)
def test_no_match_returns_none(self):
self.assertIsNone(self._extract('<lock><other>val</other></lock>'))
# ═══════════════════════════════════════════
# activate — inactivectsobjects 路径(行 235-265
# ═══════════════════════════════════════════
class TestActivateInactiveCtsObjects(unittest.TestCase):
IOC_NS = "http://www.sap.com/abapxml/inactiveCtsObjects"
def _activate_resp(self, xml):
return _mock_resp(
200, text=xml, content=xml.encode(),
headers={"content-type": "application/vnd.sap.adt.inactivectsobjects+xml"},
)
def test_with_objects_via_fallback_ref(self):
"""ref 不在 adtcore ns → 走 endswith}ref 回退路径。"""
xml = (
f'<?xml version="1.0"?>'
f'<asx:abap xmlns:asx="http://www.sap.com/abapxml"'
f' xmlns:ioc="{self.IOC_NS}" xmlns:adtcore="{ADTCORE_NS}">'
f'<ioc:entry><ioc:object>'
f'<ioc:ref adtcore:name="Z_INACTIVE" adtcore:type="CLAS/OC"/>'
f'</ioc:object></ioc:entry>'
f'</asx:abap>'
)
client = _make_client()
client.session.post.return_value = self._activate_resp(xml)
ok, msgs = client.activate("ztest", "/uri")
self.assertFalse(ok)
self.assertTrue(any("Z_INACTIVE" in m["text"] for m in msgs))
def test_with_objects_via_adtcore_ref(self):
"""ref 在 adtcore ns → obj_elem.find 直接命中。"""
xml = (
f'<?xml version="1.0"?>'
f'<asx:abap xmlns:asx="http://www.sap.com/abapxml"'
f' xmlns:ioc="{self.IOC_NS}" xmlns:adtcore="{ADTCORE_NS}">'
f'<ioc:entry><ioc:object>'
f'<adtcore:ref adtcore:name="Z_ADTC" adtcore:type="INTF/OI"/>'
f'</ioc:object></ioc:entry>'
f'</asx:abap>'
)
client = _make_client()
client.session.post.return_value = self._activate_resp(xml)
ok, msgs = client.activate("ztest", "/uri")
self.assertFalse(ok)
self.assertTrue(any("Z_ADTC" in m["text"] for m in msgs))
def test_empty_no_objects(self):
"""inactivectsobjects 响应但无 entry → 视为成功。"""
xml = (
f'<?xml version="1.0"?>'
f'<asx:abap xmlns:asx="http://www.sap.com/abapxml" xmlns:ioc="{self.IOC_NS}">'
f'</asx:abap>'
)
client = _make_client()
client.session.post.return_value = self._activate_resp(xml)
ok, msgs = client.activate("ztest", "/uri")
self.assertTrue(ok)
self.assertEqual(msgs, [])
# ═══════════════════════════════════════════
# activate — corr_nr / msg 回退 / verify 异常(行 219, 316-317, 334-337
# ═══════════════════════════════════════════
class TestActivateMisc(unittest.TestCase):
def test_activate_with_corr_nr(self):
"""传 corr_nr → 写入 activation params。"""
client = _make_client()
xml = f'<?xml version="1.0"?><adtcore:activation xmlns:adtcore="{ADTCORE_NS}"/>'
client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode())
ok, msgs = client.activate("ztest", "/uri", corr_nr="DEVK001")
self.assertTrue(ok)
self.assertEqual(msgs, [])
def test_msg_without_txt_uses_fallback_text(self):
"""msg 无 }txt 子元素 → 用第一个长文本后代(行 334-337)。"""
client = _make_client()
xml = (
f'<?xml version="1.0"?>'
f'<adtcore:activation xmlns:adtcore="{ADTCORE_NS}">'
f'<adtcore:objectReference type="E" line="1" href="">'
f'<adtcore:description>Some long activation failure text</adtcore:description>'
f'</adtcore:objectReference>'
f'</adtcore:activation>'
)
client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode())
ok, msgs = client.activate("ztest", "/uri")
self.assertFalse(ok)
self.assertTrue(msgs)
self.assertIn("Some long", msgs[0]["text"])
def test_empty_response_verify_raises(self):
"""空响应 + verify GET 抛异常 → 捕获后视为成功(行 316-317)。"""
client = _make_client()
client.session.post.return_value = _mock_resp(200, text="", content=b"")
client.session.get.side_effect = Exception("verify boom")
ok, msgs = client.activate("ztest", "/uri")
self.assertTrue(ok)
self.assertEqual(msgs, [])
# ═══════════════════════════════════════════
# syntax_check — msg 回退文本(行 390-393
# ═══════════════════════════════════════════
class TestSyntaxCheckMsgFallback(unittest.TestCase):
def test_msg_without_txt_uses_fallback_text(self):
client = _make_client()
xml = (
f'<?xml version="1.0"?>'
f'<chk:checks xmlns:chk="{CHK_NS}">'
f'<chk:msg type="E" line="1" href="">'
f'<chk:note>Another long syntax failure note</chk:note>'
f'</chk:msg>'
f'</chk:checks>'
)
client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode())
ok, msgs = client.syntax_check("ztest", "/uri")
self.assertFalse(ok)
self.assertTrue(msgs)
self.assertIn("Another long", msgs[0]["text"])
if __name__ == "__main__":
unittest.main(verbosity=2)