diff --git a/SKILL.md b/SKILL.md index ba1bb69..779034a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -90,6 +90,7 @@ Optional params in `[ ]`. 各命令参数以 `python main.py --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. diff --git a/assets/sapcli/cli/app.py b/assets/sapcli/cli/app.py index 3635cbf..b86e675 100644 --- a/assets/sapcli/cli/app.py +++ b/assets/sapcli/cli/app.py @@ -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 参数校验 diff --git a/assets/sapcli/cli/parser.py b/assets/sapcli/cli/parser.py index 94353be..b649304 100644 --- a/assets/sapcli/cli/parser.py +++ b/assets/sapcli/cli/parser.py @@ -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 diff --git a/assets/sapcli/client/_ddic.py b/assets/sapcli/client/_ddic.py index 486f665..3117800 100644 --- a/assets/sapcli/client/_ddic.py +++ b/assets/sapcli/client/_ddic.py @@ -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 + # 元素形态:normal + 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 # ------------------------------------------------------------------ diff --git a/assets/sapcli/client/_source.py b/assets/sapcli/client/_source.py index 8ca50f4..37dff86 100644 --- a/assets/sapcli/client/_source.py +++ b/assets/sapcli/client/_source.py @@ -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'' + f'' + ) body = ( '' '' - f'' + f'{refs}' "" ) self._stateful = False diff --git a/assets/sapcli/commands/__init__.py b/assets/sapcli/commands/__init__.py index e7a4818..d8aabfe 100644 --- a/assets/sapcli/commands/__init__.py +++ b/assets/sapcli/commands/__init__.py @@ -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", ] diff --git a/assets/sapcli/commands/crud.py b/assets/sapcli/commands/crud.py index 1f441d4..7936b8a 100644 --- a/assets/sapcli/commands/crud.py +++ b/assets/sapcli/commands/crud.py @@ -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 diff --git a/assets/sapcli/commands/remote_enable.py b/assets/sapcli/commands/remote_enable.py new file mode 100644 index 0000000..fb79ec8 --- /dev/null +++ b/assets/sapcli/commands/remote_enable.py @@ -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") diff --git a/assets/sapcli/scanner.py b/assets/sapcli/scanner.py index 6ec6c93..cca5229 100644 --- a/assets/sapcli/scanner.py +++ b/assets/sapcli/scanner.py @@ -79,6 +79,43 @@ SRC_DIR = "src" # ADT 式函数模块文件名分隔符:.fugr..abap FUNC_ADT_SEP = ".fugr." +# ADT/abapGit 风格的**对象类型后缀**(`..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)) diff --git a/tests/unit/test_activate.py b/tests/unit/test_activate.py index f51d9dd..4c61c54 100644 --- a/tests/unit/test_activate.py +++ b/tests/unit/test_activate.py @@ -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(" 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 ( + '' + f'' + f"{processing_type}" + "" + ) + + +# ═══════════════════════════════════════════ +# 改动 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 = ( + '' + f'' + ) + out = _set_processing_type(xml, "rfc") + self.assertEqual(_get_processing_type(out), "rfc") + + def test_missing_processing_type_raises(self): + xml = f'' + with self.assertRaises(SapCliError): + _set_processing_type(xml, "rfc") + + def test_get_processing_type_missing_returns_empty(self): + self.assertEqual(_get_processing_type(f''), "") + + +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() diff --git a/tests/unit/test_modules.py b/tests/unit/test_modules.py index e0ee61c..e07dbdb 100644 --- a/tests/unit/test_modules.py +++ b/tests/unit/test_modules.py @@ -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 风格项目(`..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 风格 `.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) diff --git a/tests/unit/test_repo_guards.py b/tests/unit/test_repo_guards.py index 514bcaa..8a95e09 100644 --- a/tests/unit/test_repo_guards.py +++ b/tests/unit/test_repo_guards.py @@ -104,6 +104,42 @@ class TestSkillDocStaysInSync(unittest.TestCase): ) +class TestScannerSupportsAdtFilenames(unittest.TestCase): + """扫描器必须支持 ADT/abapGit 风格文件名(`.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/ 是规则的正本,必须齐备。"""