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)
This commit is contained in:
@@ -90,6 +90,7 @@ Optional params in `[ ]`. 各命令参数以 `python main.py <cmd> --help` 为
|
||||
| Other | `check` | `--name --type` (ATC) |
|
||||
| Other | `format` | `--name --type` (Pretty Printer) |
|
||||
| Other | `enhancement` | `--name --type` (增强) |
|
||||
| Other | `remote-enable` | `--name <组/模块> [--corr_nr]` (FM 远程启用/bgRFC 执行体) |
|
||||
| Other | `unit-test` | `--name --type` (ABAP Unit 测试) |
|
||||
| Other | `package create` | `--name` |
|
||||
| Other | `cds download` | `--name --path` |
|
||||
@@ -120,6 +121,28 @@ python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX
|
||||
|
||||
**Function naming**: must use `group/module`, e.g. `ZMY_FGROUP/Z_MY_FUNC`.
|
||||
|
||||
### Function Module Remote-Enable(bgRFC/RFC 执行体)
|
||||
|
||||
bgRFC 的执行体必须是**远程启用的函数模块**。ADT 建 FM 默认 `processingType=normal`,
|
||||
语法检查**不校验**此标志,必须显式置 `rfc` 并以 `TFDIR.FMODE='R'` 复核。
|
||||
|
||||
```bash
|
||||
python main.py remote-enable --name ZINT_FG_INTEGRATE/ZINT_FM_LOG_COMMIT --corr_nr DEVK901376
|
||||
```
|
||||
|
||||
命令内部闭环(锁定→GET 原 XML→改 `processingType=rfc`→PUT→GET 复核→解锁),
|
||||
仅改属性,不激活、不查 TFDIR。关键事实(本机 NW 7.52 实测):
|
||||
|
||||
| 要点 | 事实 |
|
||||
|------|------|
|
||||
| `processingType` 枚举值 | 只认小写 `rfc`;`remoteEnabled`/`remote`/`RFC` 等值全 400 |
|
||||
| `lockHandle` 传递 | 必须走 query 参数 `?lockHandle=`;放 header 报 `ExceptionParameterNotFound` |
|
||||
| 参数类型限制 | 只允许 DDIC 对象(结构含 STRING 列已验证);类本地类型 → HTTP 500「针对 RFC 不允许使用类或接口的类型」;表类型参数需 TTYP 端点(本机 404) |
|
||||
| FM 源码限制 | `FUNCTION` 与 `ENDFUNCTION` 之间不得放 `*"` 参数注释块(ADT 拒绝「Parameter comment blocks are not allowed」) |
|
||||
| 最终确认 | `TFDIR.FMODE='R'` 留给调用方 `read-table` 复核(本命令不查) |
|
||||
|
||||
—— 落款:吴让宇
|
||||
|
||||
## ⚠️ Constraints (HARD RULES — DO NOT VIOLATE)
|
||||
|
||||
Full rules: `references/sap-tool-constraints.md`. Violations are intercepted by hooks.
|
||||
|
||||
@@ -42,6 +42,7 @@ from sapcli.commands import (
|
||||
cmd_history,
|
||||
cmd_clone,
|
||||
cmd_enhancement,
|
||||
cmd_remote_enable,
|
||||
)
|
||||
from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status
|
||||
from sapcli.exceptions import SapCliError, ConfigError
|
||||
@@ -164,6 +165,7 @@ def main() -> None:
|
||||
"unit-test": cmd_unit_test,
|
||||
"history": cmd_history,
|
||||
"enhancement": cmd_enhancement,
|
||||
"remote-enable": cmd_remote_enable,
|
||||
}
|
||||
|
||||
# 批量 sync 参数校验
|
||||
|
||||
@@ -292,4 +292,9 @@ CDS View:
|
||||
sp_enh.add_argument("--name", required=True, help="对象名称")
|
||||
sp_enh.add_argument("--type", required=True, help="对象类型")
|
||||
|
||||
# ── remote-enable (函数模块远程启用,bgRFC/RFC 执行体) ──
|
||||
sp_remote = subparsers.add_parser("remote-enable", help="远程启用函数模块(bgRFC/RFC 执行体)")
|
||||
sp_remote.add_argument("--name", required=True, help="函数模块名 (函数组/函数模块 格式)")
|
||||
sp_remote.add_argument("--corr_nr", default=None, help="传输请求编号")
|
||||
|
||||
return parser
|
||||
|
||||
@@ -39,6 +39,50 @@ def _find_local(container: ET.Element, name: str) -> ET.Element | None:
|
||||
return None
|
||||
|
||||
|
||||
def _set_processing_type(xml_text: str, value: str) -> str:
|
||||
"""把 FM 元数据 XML 的 ``processingType`` 字段改为 ``value``(元素/属性两种形态兼容)。
|
||||
|
||||
按本地名匹配(``fm:``/``fmodule:``/无前缀均命中),回写前注册已知命名空间
|
||||
前缀,尽量保持与原 XML 一致。实测(NW 7.52):ADT 只认小写 ``rfc``,
|
||||
``remoteEnabled``/``remote``/``RFC`` 等值全 400。
|
||||
"""
|
||||
ET.register_namespace("fm", "http://www.sap.com/adt/functions/fmodules")
|
||||
ET.register_namespace("adtcore", "http://www.sap.com/adt/core")
|
||||
root = ET.fromstring(xml_text)
|
||||
changed = False
|
||||
# 元素形态:<fm:processingType>normal</fm:processingType>
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == "processingType":
|
||||
el.text = value
|
||||
changed = True
|
||||
# 属性形态:processingType="normal"(含命名空间前缀)
|
||||
if not changed:
|
||||
for el in root.iter():
|
||||
for key in list(el.attrib.keys()):
|
||||
if _local(key) == "processingType":
|
||||
el.attrib[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
raise SapCliError("函数模块 XML 中未找到 processingType 字段,无法设置远程启用")
|
||||
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
||||
|
||||
|
||||
def _get_processing_type(xml_text: str) -> str:
|
||||
"""从 FM 元数据 XML 读取 ``processingType`` 当前值(无则返回空串)。"""
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return ""
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == "processingType":
|
||||
return (el.text or "").strip()
|
||||
for el in root.iter():
|
||||
for key, val in el.attrib.items():
|
||||
if _local(key) == "processingType":
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
# ADT lock 端点返回 ABAP 结构 XML。DDIC 对象(domain/dataelement/table/structure)
|
||||
# 的 lock 端点对默认 Accept: */* 返回 HTTP 406,必须显式请求 lock 结果类型。
|
||||
# 参考 abap-adt-api objectcontents.ts 的 lock 实现。
|
||||
@@ -531,13 +575,14 @@ class DdicMixin:
|
||||
group_name: str,
|
||||
description: str | None = None,
|
||||
corr_nr: str | None = None,
|
||||
package: str = "$TMP",
|
||||
) -> bool:
|
||||
config = get_type_config("functiongroup")
|
||||
params: dict[str, str] = {"groupname": group_name.upper()}
|
||||
if corr_nr:
|
||||
params["corrNr"] = corr_nr
|
||||
desc = description or group_name
|
||||
body = self._build_create_body("functiongroup", group_name.upper(), desc)
|
||||
body = self._build_create_body("functiongroup", group_name.upper(), desc, package)
|
||||
url = f"{self.host}{config.collection_uri}"
|
||||
hdrs = self._headers(config.create_content_type)
|
||||
logger.info("CREATE FG: POST %s name=%s", url, group_name.upper())
|
||||
@@ -552,6 +597,87 @@ class DdicMixin:
|
||||
)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Function module remote-enable(bgRFC/RFC 执行体)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_function_module_xml(self, group: str, fm: str) -> str:
|
||||
"""GET 函数模块元数据 XML(含 processingType)。
|
||||
|
||||
Args:
|
||||
group: 函数组名。
|
||||
fm: 函数模块名。
|
||||
|
||||
Returns:
|
||||
FM 元数据 XML 字符串。
|
||||
"""
|
||||
obj_uri = get_type_config("function").format_obj_uri(
|
||||
fm.lower(), group=group.lower()
|
||||
)
|
||||
url = f"{self.host}{obj_uri}"
|
||||
hdrs = self._headers()
|
||||
hdrs["Accept"] = "application/vnd.sap.adt.functions.fmodules.v2+xml"
|
||||
logger.info("GET FM XML: GET %s", url)
|
||||
resp = self.session.get(url, headers=hdrs)
|
||||
logger.info("GET FM XML RESPONSE: HTTP %s", resp.status_code)
|
||||
if resp.status_code != 200:
|
||||
raise SapCliError(
|
||||
f"读取函数模块元数据失败: HTTP {resp.status_code} — {resp.text[:500]}"
|
||||
)
|
||||
return resp.text
|
||||
|
||||
def set_function_remote_enabled(
|
||||
self,
|
||||
group: str,
|
||||
fm: str,
|
||||
corr_nr: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""远程启用函数模块:GET 元数据 → ``processingType=rfc`` → PUT → GET 复核。
|
||||
|
||||
实测(NW 7.52)唯一可行方案:PUT 原 XML 且 ``processingType`` 取小写
|
||||
``rfc``(``remoteEnabled``/``remote``/``RFC`` 等值全 400);lockHandle 必须
|
||||
走 query 参数(放 header 报 ``ExceptionParameterNotFound``)。类本地类型参数的
|
||||
FM 会 500「针对 RFC 不允许使用类或接口的类型」,错误透传给调用方。
|
||||
复核后 ``TFDIR.FMODE='R'`` 的最终确认留给调用方 read-table(文档已写清)。
|
||||
|
||||
Returns:
|
||||
``(success, error_message)``。
|
||||
"""
|
||||
obj_uri = get_type_config("function").format_obj_uri(
|
||||
fm.lower(), group=group.lower()
|
||||
)
|
||||
# 1) 锁定
|
||||
lock_handle, _ = self.lock(obj_uri, corr_nr)
|
||||
try:
|
||||
# 2) GET 原 XML
|
||||
xml_text = self.get_function_module_xml(group, fm)
|
||||
# 3) 改 processingType=rfc
|
||||
new_xml = _set_processing_type(xml_text, "rfc")
|
||||
# 4) PUT(lockHandle 走 query 参数)
|
||||
url = f"{self.host}{obj_uri}"
|
||||
params: dict[str, str] = {"lockHandle": lock_handle}
|
||||
if corr_nr:
|
||||
params["corrNr"] = corr_nr
|
||||
hdrs = self._headers(
|
||||
"application/vnd.sap.adt.functions.fmodules.v2+xml"
|
||||
)
|
||||
hdrs["Accept"] = "*/*"
|
||||
logger.info("SET FM REMOTE-ENABLE: PUT %s", url)
|
||||
resp = self.session.put(
|
||||
url, headers=hdrs, params=params, data=new_xml.encode("utf-8")
|
||||
)
|
||||
logger.info("SET FM REMOTE-ENABLE RESPONSE: HTTP %s", resp.status_code)
|
||||
if resp.status_code >= 400:
|
||||
msg = resp.text[:500].strip() if resp.text else f"HTTP {resp.status_code}"
|
||||
return False, f"远程启用失败: HTTP {resp.status_code} — {msg}"
|
||||
# 5) GET 复核回显
|
||||
verify_xml = self.get_function_module_xml(group, fm)
|
||||
if _get_processing_type(verify_xml) != "rfc":
|
||||
return False, "远程启用失败: 复核回显 processingType 不是 rfc"
|
||||
return True, ""
|
||||
finally:
|
||||
self.unlock(obj_uri, lock_handle)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CDS View
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -208,10 +208,19 @@ class SourceMixin:
|
||||
obj_uri: str,
|
||||
corr_nr: str | None = None,
|
||||
) -> tuple[bool, list[dict[str, str]]]:
|
||||
# 实测(本机 SAP_BASIS 752,2026-09-15 受控实验):
|
||||
# objectReferences 里只有 1 个 objectReference 时,服务端返回
|
||||
# HTTP 200 + 空 body(无 content-type),且根本不执行激活;
|
||||
# 引用数为 2 及以上才真正激活(同一对象重复 2 次亦可)。
|
||||
# 故单对象请求补一个重复引用,使请求体始终含 2 个引用。
|
||||
refs = (
|
||||
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
|
||||
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
|
||||
)
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<adtcore:objectReferences xmlns:adtcore="http://www.sap.com/adt/core">'
|
||||
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
|
||||
f'{refs}'
|
||||
"</adtcore:objectReferences>"
|
||||
)
|
||||
self._stateful = False
|
||||
|
||||
@@ -73,6 +73,9 @@ from sapcli.commands.clone import (
|
||||
from sapcli.commands.enhancement import (
|
||||
cmd_enhancement,
|
||||
)
|
||||
from sapcli.commands.remote_enable import (
|
||||
cmd_remote_enable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"cmd_create",
|
||||
@@ -106,4 +109,5 @@ __all__ = [
|
||||
"cmd_history",
|
||||
"cmd_clone",
|
||||
"cmd_enhancement",
|
||||
"cmd_remote_enable",
|
||||
]
|
||||
|
||||
@@ -939,7 +939,8 @@ def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
if obj_type == "functiongroup":
|
||||
print(f"\n → 正在创建函数组...")
|
||||
try:
|
||||
created_uri = client.create_function_group(name, description, corr_nr)
|
||||
package = getattr(args, "package", None) or "$TMP"
|
||||
created_uri = client.create_function_group(name, description, corr_nr, package)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
raise CreateError(str(e)) from e
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""remote-enable 命令 — 远程启用函数模块(bgRFC/RFC 执行体)。
|
||||
|
||||
背景:bgRFC 的执行体必须是远程启用的函数模块(processingType=rfc)。
|
||||
本命令封装 ADT 的 GET→改 processingType=rfc→PUT→GET 复核闭环,
|
||||
不负责激活、也不负责 TFDIR.FMODE='R' 的最终确认(后者留给调用方 read-table)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
from sapcli.exceptions import InvalidNameError, SapCliError
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.remote_enable")
|
||||
|
||||
|
||||
def cmd_remote_enable(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""远程启用函数模块。"""
|
||||
name: str = args.name
|
||||
corr_nr: str | None = getattr(args, "corr_nr", None)
|
||||
|
||||
if "/" not in name:
|
||||
print(" ✗ remote-enable 需要'函数组/函数模块'格式,例如: ZGROUP/Z_FUNC")
|
||||
raise InvalidNameError(
|
||||
"remote-enable 需要'函数组/函数模块'格式,例如: ZGROUP/Z_FUNC"
|
||||
)
|
||||
group, fm = name.split("/", 1)
|
||||
group = group.strip()
|
||||
fm = fm.strip()
|
||||
if not group or not fm:
|
||||
print(" ✗ 函数组名和函数模块名不能为空")
|
||||
raise InvalidNameError("函数组名和函数模块名不能为空")
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 函数模块远程启用(bgRFC/RFC)")
|
||||
print("=" * 60)
|
||||
print(f" 函数组: {group}")
|
||||
print(f" 函数模块: {fm}")
|
||||
if corr_nr:
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
print("\n → 正在远程启用(processingType=rfc)...")
|
||||
ok, msg = client.set_function_remote_enabled(group, fm, corr_nr)
|
||||
if not ok:
|
||||
print(f" ✗ {msg}")
|
||||
raise SapCliError(msg)
|
||||
|
||||
print(" ✓ 远程启用成功")
|
||||
print(" ✓ 复核回显 processingType=rfc")
|
||||
print()
|
||||
print(" 注意:")
|
||||
print(" - 参数类型只允许 DDIC 对象;类本地类型会 500「针对 RFC 不允许使用类或接口的类型」")
|
||||
print(" - TFDIR.FMODE='R' 的最终确认请用 read-table 复核(见 SKILL.md)")
|
||||
print(" - 此命令只改属性不激活;如需激活再执行 activate")
|
||||
@@ -79,6 +79,43 @@ SRC_DIR = "src"
|
||||
# ADT 式函数模块文件名分隔符:<fugr>.fugr.<fm>.abap
|
||||
FUNC_ADT_SEP = ".fugr."
|
||||
|
||||
# ADT/abapGit 风格的**对象类型后缀**(`<name>.<suffix>.abap`)。
|
||||
# 来源:ADT 链接格式与 abapGit 序列化——项目仓命名规范(如 ABAP_Integrated
|
||||
# 《命名规范》§4)即按此写文件名,故扫描器必须剥掉后缀才能得到对象名。
|
||||
# 仅剥与所在目录 obj_type 匹配的那一个后缀,避免误伤含点的对象名。
|
||||
ADT_TYPE_SUFFIX: dict[str, str] = {
|
||||
"class": "clas",
|
||||
"interface": "intf",
|
||||
"report": "prog",
|
||||
"domain": "doma",
|
||||
"dataelement": "dtel",
|
||||
"table": "tabl",
|
||||
"structure": "stru",
|
||||
"tabletype": "ttyp",
|
||||
"include": "cinc",
|
||||
"cdsview": "ddls",
|
||||
"messageclass": "msag",
|
||||
"searchhelp": "shlp",
|
||||
"lockobject": "enqu",
|
||||
"dcl": "dcls",
|
||||
"ddlX": "ddlx",
|
||||
}
|
||||
|
||||
|
||||
def _strip_adt_suffix(base: str, obj_type: str) -> str:
|
||||
"""剥掉 ADT 类型后缀(`zcl_demo.clas` → `zcl_demo`)。
|
||||
|
||||
只在该类型有约定后缀、且文件名确实以 `.<该后缀>` 结尾时剥,
|
||||
其余情况原样返回(含点的对象名不受影响)。
|
||||
"""
|
||||
suffix = ADT_TYPE_SUFFIX.get(obj_type)
|
||||
if not suffix:
|
||||
return base
|
||||
tail = "." + suffix
|
||||
if base.lower().endswith(tail) and len(base) > len(tail):
|
||||
return base[: -len(tail)]
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannedObject:
|
||||
@@ -176,7 +213,7 @@ def _scan_flat_directory(
|
||||
if not os.path.isfile(os.path.join(dir_path, filename)):
|
||||
continue
|
||||
|
||||
obj_name = filename[:-5].upper() # 去掉 .abap,转大写
|
||||
obj_name = _strip_adt_suffix(filename[:-5], obj_type).upper() # 去 .abap + ADT 类型后缀
|
||||
_add(results, seen, obj_name, obj_type, _rel(rel_prefix, dir_name, filename))
|
||||
|
||||
|
||||
|
||||
@@ -167,3 +167,53 @@ class TestCmdActivateNoneMode(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestActivateRequestBodyHasTwoRefs(unittest.TestCase):
|
||||
"""守卫:activation 请求体必须含 ≥2 个 objectReference 引用。
|
||||
|
||||
实测(SAP_BASIS 752,2026-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)
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""bgRFC 支撑能力守卫测试(B1 收尾并行件)。
|
||||
|
||||
守住三处「静默失效」类不变量,全部 mock/离线,不连接 SAP:
|
||||
|
||||
1. **create --type functiongroup 的 --package 透传**:包名必须进入创建 body 的
|
||||
``packageRef``(否则 FUGR 静默落 $TMP,无法归 ZINT 包)。
|
||||
2. **FM 远程启用**:``processingType`` 只认小写 ``rfc``;lockHandle 必须走 query
|
||||
参数(放 header 报 ExceptionParameterNotFound);PUT 后 GET 复核回显。
|
||||
3. **function 类型的 '组/模块' URL 组装**:``fmodules/{name}`` 段必须是模块名,
|
||||
不是组名(曾出现 URL 误用组名 → 400)。
|
||||
|
||||
运行: python -m unittest tests.unit.test_bgrfc_support -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
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.client._ddic import _set_processing_type, _get_processing_type
|
||||
from sapcli.exceptions import InvalidNameError, SapCliError
|
||||
from sapcli.types import parse_object_name
|
||||
|
||||
ADTCORE_NS = "http://www.sap.com/adt/core"
|
||||
FM_NS = "http://www.sap.com/adt/functions/fmodules"
|
||||
LOCK_NS = "http://www.sap.com/adt/lock"
|
||||
|
||||
|
||||
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 _make_mock_client() -> MagicMock:
|
||||
"""全 mock 客户端(命令级测试用,方法均为 MagicMock)。"""
|
||||
client = MagicMock(spec=ADTClient)
|
||||
client.host = "https://sap.example.com"
|
||||
client.sap_client = "100"
|
||||
client.csrf_token = "test-csrf-token"
|
||||
client.user = "TESTUSER"
|
||||
client.session = MagicMock()
|
||||
return client
|
||||
|
||||
|
||||
def _mock_resp(status_code=200, text="", content=b"", headers=None):
|
||||
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 _fm_xml(processing_type: str) -> str:
|
||||
"""构造带 processingType 的 FM 元数据 XML。"""
|
||||
return (
|
||||
'<?xml version="1.0"?>'
|
||||
f'<fm:abapFunctionModule xmlns:fm="{FM_NS}" xmlns:adtcore="{ADTCORE_NS}">'
|
||||
f"<fm:processingType>{processing_type}</fm:processingType>"
|
||||
"</fm:abapFunctionModule>"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 改动 1:functiongroup 的 --package 透传
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestFunctionGroupPackagePassThrough(unittest.TestCase):
|
||||
"""FUGR 创建必须把 package 写进 body 的 packageRef,否则静默落 $TMP。"""
|
||||
|
||||
def test_create_function_group_body_has_package_ref(self):
|
||||
client = _make_client()
|
||||
client.session.post.return_value = _mock_resp(201)
|
||||
|
||||
self.assertTrue(client.create_function_group("ZFG", "desc", package="ZINT"))
|
||||
|
||||
_, kwargs = client.session.post.call_args
|
||||
body = kwargs["data"].decode("utf-8")
|
||||
self.assertIn("packageRef", body, "FUGR 创建 body 必须含 packageRef")
|
||||
self.assertIn("ZINT", body, "packageRef 必须写 ZINT,而非 $TMP")
|
||||
self.assertNotIn("$TMP", body)
|
||||
|
||||
def test_create_function_group_default_package_is_tmp(self):
|
||||
"""未显式传 package 时仍默认 $TMP(向后兼容)。"""
|
||||
client = _make_client()
|
||||
client.session.post.return_value = _mock_resp(201)
|
||||
client.create_function_group("ZFG", "desc")
|
||||
_, kwargs = client.session.post.call_args
|
||||
body = kwargs["data"].decode("utf-8")
|
||||
self.assertIn("$TMP", body)
|
||||
|
||||
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
|
||||
def test_cmd_create_functiongroup_passes_package(self, mock_transport):
|
||||
"""cmd_create --type functiongroup --package ZINT → create_function_group 收到 package。"""
|
||||
from sapcli.commands.crud import cmd_create
|
||||
|
||||
client = _make_mock_client()
|
||||
client.function_group_exists.return_value = False
|
||||
client.create_function_group.return_value = True
|
||||
|
||||
cmd_create(
|
||||
Namespace(
|
||||
type="functiongroup", name="ZFG", description="d",
|
||||
source=None, definition=None, package="ZINT", corr_nr=None,
|
||||
path=None,
|
||||
),
|
||||
client,
|
||||
)
|
||||
|
||||
client.create_function_group.assert_called_once()
|
||||
pos = client.create_function_group.call_args[0]
|
||||
# 签名: (group_name, description, corr_nr, package)
|
||||
self.assertEqual(pos[0], "ZFG")
|
||||
self.assertEqual(pos[3], "ZINT", "package 必须透传到 create_function_group")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 改动 2:FM 远程启用(processingType=rfc + lockHandle query)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestProcessingTypeHelpers(unittest.TestCase):
|
||||
"""_set_processing_type / _get_processing_type 的 XML 改写。"""
|
||||
|
||||
def test_element_form_set_and_get(self):
|
||||
out = _set_processing_type(_fm_xml("normal"), "rfc")
|
||||
self.assertEqual(_get_processing_type(out), "rfc")
|
||||
self.assertIn("rfc", out)
|
||||
self.assertNotIn(">normal<", out)
|
||||
|
||||
def test_attribute_form_set_and_get(self):
|
||||
xml = (
|
||||
'<?xml version="1.0"?>'
|
||||
f'<fm:abapFunctionModule xmlns:fm="{FM_NS}" processingType="normal"/>'
|
||||
)
|
||||
out = _set_processing_type(xml, "rfc")
|
||||
self.assertEqual(_get_processing_type(out), "rfc")
|
||||
|
||||
def test_missing_processing_type_raises(self):
|
||||
xml = f'<fm:abapFunctionModule xmlns:fm="{FM_NS}"/>'
|
||||
with self.assertRaises(SapCliError):
|
||||
_set_processing_type(xml, "rfc")
|
||||
|
||||
def test_get_processing_type_missing_returns_empty(self):
|
||||
self.assertEqual(_get_processing_type(f'<x xmlns:x="urn:x"/>'), "")
|
||||
|
||||
|
||||
class TestSetFunctionRemoteEnabled(unittest.TestCase):
|
||||
"""客户端 set_function_remote_enabled 的 GET→PUT→GET 闭环。"""
|
||||
|
||||
def _setup(self):
|
||||
client = _make_client()
|
||||
client.session.get.side_effect = [
|
||||
_mock_resp(200, text=_fm_xml("normal")), # 首次 GET 原 XML
|
||||
_mock_resp(200, text=_fm_xml("rfc")), # PUT 后 GET 复核
|
||||
]
|
||||
client.session.put.return_value = _mock_resp(200)
|
||||
client.lock = MagicMock(return_value=("LH123", None))
|
||||
client.unlock = MagicMock(return_value=True)
|
||||
return client
|
||||
|
||||
def test_put_lockhandle_in_query_not_header(self):
|
||||
client = self._setup()
|
||||
ok, err = client.set_function_remote_enabled("ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM")
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(err, "")
|
||||
_, put_kwargs = client.session.put.call_args
|
||||
# 红线:lockHandle 必须走 query 参数,不能放 header
|
||||
self.assertEqual(put_kwargs["params"].get("lockHandle"), "LH123")
|
||||
self.assertNotIn("lockHandle", put_kwargs.get("headers", {}))
|
||||
|
||||
def test_put_body_uses_lowercase_rfc(self):
|
||||
client = self._setup()
|
||||
client.set_function_remote_enabled("ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM")
|
||||
_, put_kwargs = client.session.put.call_args
|
||||
body = put_kwargs["data"].decode("utf-8")
|
||||
self.assertIn(">rfc<", body, "processingType 必须是元素形态小写 rfc")
|
||||
self.assertNotIn(">normal<", body)
|
||||
# 禁止 remoteEnabled/remote/RFC 等错误枚举值
|
||||
self.assertNotIn("remoteEnabled", body)
|
||||
self.assertNotIn(">RFC<", body)
|
||||
|
||||
def test_put_url_is_group_slash_module(self):
|
||||
client = self._setup()
|
||||
client.set_function_remote_enabled("ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM")
|
||||
url = client.session.put.call_args[0][0]
|
||||
self.assertIn(
|
||||
"/sap/bc/adt/functions/groups/zz_rfc_probe/fmodules/zz_rfc_probe_fm",
|
||||
url,
|
||||
)
|
||||
|
||||
def test_put_failure_returns_error_transparently(self):
|
||||
"""PUT 500(类本地类型参数)错误信息透传。"""
|
||||
client = self._setup()
|
||||
client.session.put.return_value = _mock_resp(
|
||||
500, text="针对 RFC 不允许使用类或接口的类型"
|
||||
)
|
||||
ok, err = client.set_function_remote_enabled("ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("类或接口", err)
|
||||
# 失败也须解锁
|
||||
client.unlock.assert_called_once()
|
||||
|
||||
def test_verify_mismatch_returns_error(self):
|
||||
"""PUT 成功但 GET 复核回显不是 rfc → 判失败。"""
|
||||
client = _make_client()
|
||||
client.session.get.side_effect = [
|
||||
_mock_resp(200, text=_fm_xml("normal")),
|
||||
_mock_resp(200, text=_fm_xml("normal")), # 复核仍 normal
|
||||
]
|
||||
client.session.put.return_value = _mock_resp(200)
|
||||
client.lock = MagicMock(return_value=("LH123", None))
|
||||
client.unlock = MagicMock(return_value=True)
|
||||
ok, err = client.set_function_remote_enabled("ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("复核", err)
|
||||
|
||||
|
||||
class TestCmdRemoteEnable(unittest.TestCase):
|
||||
"""remote-enable 命令级守卫。"""
|
||||
|
||||
def _args(self, **kwargs):
|
||||
defaults = {"name": "ZZ_RFC_PROBE/ZZ_RFC_PROBE_FM", "corr_nr": None}
|
||||
defaults.update(kwargs)
|
||||
return Namespace(**defaults)
|
||||
|
||||
def test_requires_group_slash_module(self):
|
||||
from sapcli.commands.remote_enable import cmd_remote_enable
|
||||
client = _make_mock_client()
|
||||
with self.assertRaises(InvalidNameError):
|
||||
cmd_remote_enable(self._args(name="ZZ_RFC_PROBE_FM"), client)
|
||||
|
||||
def test_success_path(self):
|
||||
from sapcli.commands.remote_enable import cmd_remote_enable
|
||||
client = _make_mock_client()
|
||||
client.set_function_remote_enabled.return_value = (True, "")
|
||||
with patch("builtins.print"):
|
||||
cmd_remote_enable(self._args(), client)
|
||||
client.set_function_remote_enabled.assert_called_once_with(
|
||||
"ZZ_RFC_PROBE", "ZZ_RFC_PROBE_FM", None
|
||||
)
|
||||
|
||||
def test_failure_raises_with_server_message(self):
|
||||
from sapcli.commands.remote_enable import cmd_remote_enable
|
||||
client = _make_mock_client()
|
||||
client.set_function_remote_enabled.return_value = (
|
||||
False, "远程启用失败: HTTP 500 — 针对 RFC 不允许使用类或接口的类型",
|
||||
)
|
||||
with patch("builtins.print"):
|
||||
with self.assertRaises(SapCliError) as ctx:
|
||||
cmd_remote_enable(self._args(), client)
|
||||
self.assertIn("类或接口", str(ctx.exception))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 改动 3:function 类型 '组/模块' URL 组装守卫
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestFunctionUriAssembly(unittest.TestCase):
|
||||
"""fmodules/{name} 段必须是模块名,绝不能误用组名。"""
|
||||
|
||||
def test_parse_object_name_not_group_swapped(self):
|
||||
p = parse_object_name("ZZ_RFC_PROBE/ZZ_RFC_PROBE_FM", "function")
|
||||
self.assertIn(
|
||||
"/fmodules/zz_rfc_probe_fm", p.obj_uri,
|
||||
"obj_uri 的 fmodules 段必须是模块名",
|
||||
)
|
||||
self.assertIn(
|
||||
"/fmodules/zz_rfc_probe_fm/source/main", p.src_uri,
|
||||
"src_uri 的 fmodules 段必须是模块名",
|
||||
)
|
||||
self.assertIn("/groups/zz_rfc_probe/", p.obj_uri, "groups 段是组名")
|
||||
self.assertNotIn("/fmodules/zz_rfc_probe/source", p.src_uri,
|
||||
"禁止用组名 zz_rfc_probe 当模块名")
|
||||
|
||||
def test_parse_object_name_missing_slash_raises(self):
|
||||
with self.assertRaises(InvalidNameError):
|
||||
parse_object_name("ZZ_RFC_PROBE_FM", "function")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -442,6 +442,55 @@ class TestScanProject(unittest.TestCase):
|
||||
self.assertEqual(file_map["ZGROUP/ZFUNC1"], "functions/zgroup/zfunc1.abap")
|
||||
self.assertEqual(file_map["ZDOM_TEST"], "domains/zdom_test.abap")
|
||||
|
||||
def _make_adt_project(self, tmp: str) -> None:
|
||||
"""创建 ADT/abapGit 风格项目(`<name>.<suffix>.abap`,项目仓命名规范形态)。"""
|
||||
os.makedirs(os.path.join(tmp, "src", "ZINT.package", "class"))
|
||||
cls_dir = os.path.join(tmp, "src", "ZINT.package", "class")
|
||||
# ADT 式:zcl_demo.clas.abap
|
||||
with open(os.path.join(cls_dir, "zcl_demo.clas.abap"), "w") as f:
|
||||
f.write("CLASS zcl_demo DEFINITION.\n")
|
||||
# 无后缀式(历史写法,仍须兼容):zcl_plain.abap
|
||||
with open(os.path.join(cls_dir, "zcl_plain.abap"), "w") as f:
|
||||
f.write("CLASS zcl_plain DEFINITION.\n")
|
||||
# 后缀与目录类型不匹配:不得被剥(否则对象名会被错改)
|
||||
with open(os.path.join(cls_dir, "zcl_weird.prog.abap"), "w") as f:
|
||||
f.write("CLASS zcl_weird DEFINITION.\n")
|
||||
|
||||
def test_scan_adt_style_filenames(self):
|
||||
"""ADT 风格 `<name>.clas.abap` 必须解析为对象名 ZCL_DEMO(不带 .CLAS)。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_adt_project(tmp)
|
||||
results = scan_project(tmp)
|
||||
names = {r.name for r in results}
|
||||
|
||||
self.assertIn("ZCL_DEMO", names)
|
||||
self.assertNotIn("ZCL_DEMO.CLAS", names)
|
||||
# 无后缀式兼容
|
||||
self.assertIn("ZCL_PLAIN", names)
|
||||
# 目录类型不匹配的后缀不剥
|
||||
self.assertIn("ZCL_WEIRD.PROG", names)
|
||||
|
||||
def test_scan_adt_style_file_paths(self):
|
||||
"""ADT 风格项目下 file 字段仍为仓内相对路径。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_adt_project(tmp)
|
||||
file_map = {r.name: r.file for r in scan_project(tmp)}
|
||||
self.assertEqual(
|
||||
file_map["ZCL_DEMO"],
|
||||
"src/ZINT.package/class/zcl_demo.clas.abap",
|
||||
)
|
||||
|
||||
def test_strip_adt_suffix_unit(self):
|
||||
"""后缀剥离函数单测:只剥匹配目录类型的那一个后缀。"""
|
||||
from sapcli.scanner import _strip_adt_suffix
|
||||
self.assertEqual(_strip_adt_suffix("zcl_demo.clas", "class"), "zcl_demo")
|
||||
self.assertEqual(_strip_adt_suffix("zcl_demo", "class"), "zcl_demo")
|
||||
self.assertEqual(_strip_adt_suffix("zcl_demo.prog", "class"), "zcl_demo.prog")
|
||||
self.assertEqual(_strip_adt_suffix("zrpt1.prog", "report"), "zrpt1")
|
||||
self.assertEqual(_strip_adt_suffix("zdom.doma", "domain"), "zdom")
|
||||
# 后缀即全部文件名时不得剥成空串
|
||||
self.assertEqual(_strip_adt_suffix("clas", "class"), "clas")
|
||||
|
||||
def test_scan_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
results = scan_project(tmp)
|
||||
|
||||
@@ -104,6 +104,42 @@ class TestSkillDocStaysInSync(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestScannerSupportsAdtFilenames(unittest.TestCase):
|
||||
"""扫描器必须支持 ADT/abapGit 风格文件名(`<name>.clas.abap`)。
|
||||
|
||||
背景:项目仓命名规范(如 ABAP_Integrated《命名规范》§4)要求 ADT 风格文件名,
|
||||
而扫描器曾只剥 `.abap`,把 `zcl_demo.clas.abap` 解析成对象名 `ZCL_DEMO.CLAS`——
|
||||
对象名带伪后缀,`sync/refresh/diff --all` 全部落空。目录规范只写在文档、
|
||||
工具却不支持的同类问题,用守卫挡住。
|
||||
"""
|
||||
|
||||
def test_every_type_with_adt_suffix_maps_to_known_type(self):
|
||||
from sapcli.scanner import ADT_TYPE_SUFFIX, TYPE_DIR_LOOKUP
|
||||
|
||||
known = set(TYPE_DIR_LOOKUP.values())
|
||||
unknown = [t for t in ADT_TYPE_SUFFIX if t not in known]
|
||||
self.assertFalse(unknown, f"ADT 后缀表引用了未注册的对象类型: {unknown}")
|
||||
|
||||
def test_adt_suffix_stripped_for_all_registered_types(self):
|
||||
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
|
||||
|
||||
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
|
||||
name = f"zobj_{obj_type.lower()}"
|
||||
self.assertEqual(
|
||||
_strip_adt_suffix(f"{name}.{suffix}", obj_type), name,
|
||||
f"{obj_type} 的 ADT 后缀 .{suffix} 未被剥离",
|
||||
)
|
||||
|
||||
def test_plain_filename_unaffected(self):
|
||||
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
|
||||
|
||||
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
|
||||
name = f"zobj_{obj_type.lower()}"
|
||||
self.assertEqual(_strip_adt_suffix(name, obj_type), name)
|
||||
# 其他类型的后缀不得被误剥
|
||||
self.assertEqual(_strip_adt_suffix(f"{name}.{suffix}", "unknown_type"), f"{name}.{suffix}")
|
||||
|
||||
|
||||
class TestReferencesAreComplete(unittest.TestCase):
|
||||
"""references/ 是规则的正本,必须齐备。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user