Files
吴让宇 c06e350f6d
CI / test (3.10) (push) Waiting to run
CI / test (3.11) (push) Waiting to run
CI / test (3.12) (push) Waiting to run
feat: bgRFC/FM 支撑能力 + ADT 后缀剥离与 CLAS 激活修复(B1 配套)
新增能力:
- remote-enable 子命令:设置函数模块处理类型为远程启用(bgRFC/RFC 执行体必需)。
  实现要点均按实机验证:PUT fmodule:processingType="rfc"(枚举值仅小写 rfc 有效,
  remoteEnabled/remote/RFC 等均 400);lockHandle 走 query 参数(放 header 报
  ParameterNotFound);PUT 后 GET 复核回显;DDIC 结构参数才可远程启用(类本地类型
  报"针对 RFC 不允许使用类或接口的类型")。
- create --type functiongroup 透传 --package / --corr_nr(此前 package 被静默写成 $TMP,
  导致函数组无法归目标包)。

修复:
- activate 请求体补重复引用:ADT CLAS 激活端点对 objectReferences 只有 1 个引用时
  返回 HTTP 200 + 空 body 且不执行激活;引用数 ≥2 才真正激活(同对象重复亦可)。
- scanner 剥离 ADT 文件后缀(xxx.clas.abap 解析错误)。

守卫测试:
- tests/unit/test_bgrfc_support.py(17 例):函数组 package 透传、processingType 助手、
  remote-enable 命令(含 lockHandle 走 query/小写 rfc/错误透传/复核失配判失败)、
  函数 URI 组装不得用组名冒充模块名。
- test_activate.py 增加激活请求体引用数守卫;test_repo_guards.py 增加后缀剥离守卫。

测试:tests/unit 676 + tests/test_sapcli.py 69 = 745 全绿
(PYTHONPATH=assets python -m unittest discover -s tests/unit -t tests)
2026-09-15 22:51:35 +08:00

220 lines
7.6 KiB
Python
Raw Permalink 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.
"""Tests for sapcli.commands.activate — activate command."""
import argparse
import unittest
from unittest.mock import MagicMock
from sapcli.commands.activate import cmd_activate
def _make_client():
"""创建完整 mock 的 ADTClient。"""
client = MagicMock()
client.host = "https://sap.example.com"
client.sap_client = "100"
client.csrf_token = "test-csrf-token"
client.user = "TESTUSER"
client.session = MagicMock()
client._headers.return_value = {
"content-type": "application/xml",
"x-csrf-token": "test-csrf-token",
}
return client
class TestCmdActivateSingle(unittest.TestCase):
"""单对象激活测试。"""
def test_activate_success(self):
"""激活成功,无消息。"""
client = _make_client()
client.activate.return_value = (True, [])
args = argparse.Namespace(
name="ZTEST", type="report",
names=None, types=None, corr_nr=None, config=None,
)
cmd_activate(args, client)
client.activate.assert_called_once_with(
"ZTEST", "/sap/bc/adt/programs/programs/ztest", None,
)
def test_activate_success_with_corr_nr(self):
"""带传输请求号激活。"""
client = _make_client()
client.activate.return_value = (True, [])
args = argparse.Namespace(
name="ZTEST", type="class",
names=None, types=None, corr_nr="DEVK901362", config=None,
)
cmd_activate(args, client)
client.activate.assert_called_once_with(
"ZTEST", "/sap/bc/adt/oo/classes/ztest", "DEVK901362",
)
def test_activate_with_errors(self):
"""激活失败,返回错误消息。"""
client = _make_client()
client.activate.return_value = (
False,
[{"type": "E", "line": "10", "text": "Syntax error", "href": ""}],
)
args = argparse.Namespace(
name="ZMY_CLASS", type="class",
names=None, types=None, corr_nr=None, config=None,
)
cmd_activate(args, client)
client.activate.assert_called_once()
def test_activate_with_warnings(self):
"""激活成功但有警告。"""
client = _make_client()
client.activate.return_value = (
True,
[{"type": "W", "line": "5", "text": "Unused variable", "href": ""}],
)
args = argparse.Namespace(
name="ZTEST", type="report",
names=None, types=None, corr_nr=None, config=None,
)
cmd_activate(args, client) # 不抛异常
def test_activate_exception(self):
"""activate 抛出异常时优雅处理。"""
client = _make_client()
client.activate.side_effect = Exception("HTTP 500")
args = argparse.Namespace(
name="ZTEST", type="report",
names=None, types=None, corr_nr=None, config=None,
)
cmd_activate(args, client) # 不抛异常,打印错误
class TestCmdActivateBatch(unittest.TestCase):
"""批量激活测试。"""
def test_batch_activate_all_success(self):
"""批量激活全部成功。"""
client = _make_client()
client.activate.return_value = (True, [])
args = argparse.Namespace(
name=None, type=None,
names="ZCLS1,ZCLS2,ZCLS3",
types="class,class,class",
corr_nr=None, config=None,
)
cmd_activate(args, client)
self.assertEqual(client.activate.call_count, 3)
def test_batch_activate_with_corr_nr(self):
"""批量激活带传输请求号。"""
client = _make_client()
client.activate.return_value = (True, [])
args = argparse.Namespace(
name=None, type=None,
names="ZCLS1,ZREP1",
types="class,report",
corr_nr="DEVK901368", config=None,
)
cmd_activate(args, client)
calls = client.activate.call_args_list
self.assertEqual(len(calls), 2)
# 验证都传了 corr_nr
for call in calls:
self.assertEqual(call[0][2], "DEVK901368")
def test_batch_activate_mismatch_count(self):
"""names 和 types 数量不匹配时报错。"""
client = _make_client()
args = argparse.Namespace(
name=None, type=None,
names="ZCLS1,ZCLS2",
types="class",
corr_nr=None, config=None,
)
cmd_activate(args, client)
client.activate.assert_not_called()
def test_batch_activate_partial_failure(self):
"""批量激活部分失败。"""
client = _make_client()
client.activate.side_effect = [
(True, []),
(False, [{"type": "E", "line": "1", "text": "Error", "href": ""}]),
]
args = argparse.Namespace(
name=None, type=None,
names="ZCLS1,ZCLS2",
types="class,class",
corr_nr=None, config=None,
)
cmd_activate(args, client)
self.assertEqual(client.activate.call_count, 2)
class TestCmdActivateNoneMode(unittest.TestCase):
"""既没有 --name 也没有 --names 的情况。"""
def test_no_name_no_names(self):
"""没有提供 name 或 names 时,访问 args.name 应为 None,触发 AttributeError。"""
client = _make_client()
args = argparse.Namespace(
name=None, type=None,
names=None, types=None, corr_nr=None, config=None,
)
# name=None 传给 parse_object_name 会失败
with self.assertRaises((TypeError, AttributeError, ValueError)):
cmd_activate(args, client)
if __name__ == "__main__":
unittest.main()
class TestActivateRequestBodyHasTwoRefs(unittest.TestCase):
"""守卫:activation 请求体必须含 ≥2 个 objectReference 引用。
实测(SAP_BASIS 7522026-09-15 受控实验):objectReferences 里只有 1 个
objectReference 时,服务端返回 HTTP 200 + 空 body(无 content-type)且
根本不执行激活;引用数为 2 及以上才真正激活(同一对象重复 2 次亦可)。
单对象 activate 因此必须补重复引用,否则「激活成功」是假象。
"""
def _capture_body(self, name, uri, corr=None):
from sapcli.client._source import SourceMixin
class _Client(SourceMixin):
def __init__(self):
self.host = "https://sap.example.com"
self._stateful = False
self.session = MagicMock()
self._headers = MagicMock(return_value={})
resp = MagicMock()
resp.status_code = 200
resp.content = b""
resp.text = ""
resp.headers = {"content-type": "text/plain"}
self.session.post.return_value = resp
self.session.get.return_value = resp
c = _Client()
try:
c.activate(name, uri, corr)
except Exception:
pass
body = c.session.post.call_args[1].get("data", "")
return body
def test_single_object_request_has_two_refs(self):
body = self._capture_body(
"ZINT_CL_LOG_API", "/sap/bc/adt/oo/classes/zint_cl_log_api")
n = body.count("<adtcore:objectReference ")
self.assertGreaterEqual(
n, 2,
f"单对象 activate 请求体只有 {n} 个引用 —— 服务端将返回空响应且不激活")
def test_request_includes_target_object(self):
body = self._capture_body(
"ZINT_CL_LOG_API", "/sap/bc/adt/oo/classes/zint_cl_log_api")
self.assertIn("zint_cl_log_api", body)
self.assertIn("ZINT_CL_LOG_API", body)