Files
吴让宇 86519c51d6 fix(info): 406 回退值改为 */* —— 原值 application/xml 本身也返回 406
`info` 在 NW 7.40 上对 DDIC 类型恒报「查询失败: HTTP 406」,实测根因:
回退分支把 Accept 从专属媒体类型换成 `application/xml`,而该值在本系统同样返回 406,
等于没回退。真实客户端 `_headers()` 的 Accept 默认值本就是 `*/*`,此处相当于
把可用默认值覆盖成了失效值。

实测(S4T / NW 7.40,structure ZMM_BIP_001_HEADER):
  application/vnd.sap.adt.ddic.structures.v1|v2|v3+xml → 406
  application/xml                                      → 406
  */*                                                  → 200
修后真机验证:`info --name ZMM_BIP_001_HEADER --type structure` 查询成功。

已知限制(非本修复引入):NW 7.40 的 DDIC 端点在 */* 下只返回 blueSource,
不含 description/packageRef,故 structure/tabletype 等类型的「描述/开发包/负责人」
为空。需这些字段时用 read-table 查 DD02T(描述)/ TADIR(开发包)。
(objectproperties 端点在本系统返回 422,不可用。)

测试:
- 新增 test_info_406_fallback_accept_is_wildcard —— 断言回退 Accept 必须为 */*,
  且明确排除 application/xml。**必须用 side_effect 在请求发生时快照 headers**:
  cmd_info 重试时复用同一个 hdrs dict,直接读 call_args_list[i].kwargs["headers"]
  会因别名而全部显示最后一次赋的值,令该断言永远无法成立。
- 三个测试文件的 _make_client() 由 `_headers.return_value={...}`(共享 dict)
  改为 side_effect 返回新 dict,与真实实现一致——原写法会让被记录的 headers
  全部指向同一对象,使 header 断言失真。
- 反向验证:把回退改回 application/xml → 测试报红 AssertionError('application/xml' != '*/*')。

699 tests OK。
2026-09-11 00:53:02 +08:00

605 lines
27 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.
"""commands/ 模块单元测试
运行: python tests/unit/test_commands.py
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch, PropertyMock
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
from sapcli.client import ADTClient
def _mock_resp(status_code=200, text="", content=b"", headers=None):
r = MagicMock()
r.status_code = status_code
r.text = text
r.content = content
r.headers = headers or {}
r.raise_for_status = MagicMock()
return r
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 用于直接 HTTP 调用
client.session = MagicMock()
# 真实 _headers() 每次返回**新** dict。用 return_value 会让所有被记录的
# kwargs["headers"] 指向同一对象——后续赋值会覆盖历史值,令 header 断言失真
# (实测:406 回退的 Accept 断言曾被此别名效应蒙过)。
client._headers.side_effect = lambda content_type="application/xml": {
"content-type": content_type,
"x-csrf-token": "test-csrf-token",
"Accept": "*/*",
}
return client
def _args(**kwargs):
defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
# ═══════════════════════════════════════════
# crud.py — download
# ═══════════════════════════════════════════
class TestCmdDownload(unittest.TestCase):
def test_download_report_success(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'."
with tempfile.TemporaryDirectory() as td:
cmd_download(_args(path=td), client)
self.assertTrue(os.path.isfile(os.path.join(td, "ztest.abap")))
def test_download_object_not_found(self):
from sapcli.commands.crud import cmd_download
from sapcli.exceptions import ObjectNotFoundError
client = _make_client()
client.object_exists.return_value = False
with tempfile.TemporaryDirectory() as td:
with self.assertRaises(ObjectNotFoundError):
cmd_download(_args(path=td), client)
def test_download_creates_dir(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "REPORT ztest."
with tempfile.TemporaryDirectory() as td:
subdir = os.path.join(td, "sub", "dir")
cmd_download(_args(path=subdir), client)
self.assertTrue(os.path.isfile(os.path.join(subdir, "ztest.abap")))
def test_download_class(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "CLASS zcl_test DEFINITION."
with tempfile.TemporaryDirectory() as td:
cmd_download(_args(type="class", name="ZCL_TEST", path=td), client)
self.assertTrue(os.path.isfile(os.path.join(td, "zcl_test.abap")))
# ═══════════════════════════════════════════
# crud.py — sync
# ═══════════════════════════════════════════
class TestCmdSync(unittest.TestCase):
def test_sync_report_success(self):
from sapcli.commands.crud import cmd_sync
client = _make_client()
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.\nWRITE: / 'hello'.")
tmpfile = f.name
try:
cmd_sync(_args(path=tmpfile, project_path=None), client)
client.lock.assert_called_once()
client.set_source.assert_called_once()
finally:
os.unlink(tmpfile)
def test_sync_file_not_found(self):
from sapcli.commands.crud import cmd_sync
from sapcli.exceptions import ConfigError
client = _make_client()
with self.assertRaises(ConfigError):
cmd_sync(_args(path="/nonexistent/file.abap"), client)
def test_sync_with_check(self):
from sapcli.commands.crud import cmd_sync
client = _make_client()
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
client.syntax_check.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_sync(_args(path=tmpfile, check=True, project_path=None), client)
client.syntax_check.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# crud.py — info(直接用 client.session.get
# ═══════════════════════════════════════════
_INFO_XML = (
b'<?xml version="1.0"?>'
b'<adtcore:object xmlns:adtcore="http://www.sap.com/adt/core"'
b' adtcore:name="ZTEST" adtcore:type="PROG/P"'
b' adtcore:version="active" adtcore:description="Test report"'
b' adtcore:masterLanguage="EN"/>'
)
class TestCmdInfo(unittest.TestCase):
def test_info_success(self):
from sapcli.commands.crud import cmd_info
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_INFO_XML)
cmd_info(_args(), client)
client.session.get.assert_called()
def test_info_not_exists(self):
from sapcli.commands.crud import cmd_info
from sapcli.exceptions import ObjectNotFoundError
client = _make_client()
client.session.get.return_value = _mock_resp(404)
with self.assertRaises(ObjectNotFoundError):
cmd_info(_args(), client)
# ═══════════════════════════════════════════
# crud.py — delete
# ═══════════════════════════════════════════
class TestCmdDelete(unittest.TestCase):
@patch("builtins.input", return_value="yes")
@patch("sapcli.commands.crud.Manifest")
def test_delete_success(self, MockManifest, mock_input):
from sapcli.commands.crud import cmd_delete
client = _make_client()
client.object_exists.return_value = True
client.delete_object.return_value = (True, "")
cmd_delete(_args(), client)
client.delete_object.assert_called_once()
def test_delete_failure(self):
from sapcli.commands.crud import cmd_delete
from sapcli.exceptions import DeleteError
client = _make_client()
client.object_exists.return_value = True
client.delete_object.side_effect = DeleteError("删除失败")
with self.assertRaises(DeleteError), patch("builtins.input", return_value="yes"):
cmd_delete(_args(), client)
# ═══════════════════════════════════════════
# crud.py — create
# ═══════════════════════════════════════════
class TestCmdCreate(unittest.TestCase):
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_report_no_source(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
cmd_create(_args(source=None, description="Test", package="$TMP", corr_nr=None), client)
client.create_object.assert_called_once()
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_with_source_file(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_create(_args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), client)
finally:
os.unlink(tmpfile)
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_ddic_type(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_ddic_object.return_value = ("/uri/ztest_dom", "/uri/ztest_dom/source/main")
cmd_create(_args(type="domain", name="ZTEST_DOM", source=None, description="Domain", package="$TMP", corr_nr=None), client)
client.create_ddic_object.assert_called_once()
# ═══════════════════════════════════════════
# crud.py — print_source_preview
# ═══════════════════════════════════════════
class TestPrintSourcePreview(unittest.TestCase):
def test_preview_short(self):
from sapcli.commands.crud import print_source_preview
with patch("builtins.print"):
print_source_preview("REPORT ztest.\nWRITE: / 'hello'.")
def test_preview_long(self):
from sapcli.commands.crud import print_source_preview
long_src = "\n".join(f"LINE {i}" for i in range(100))
with patch("builtins.print"):
print_source_preview(long_src, max_lines=5)
# ═══════════════════════════════════════════
# batch.py — init
# ═══════════════════════════════════════════
class TestCmdInit(unittest.TestCase):
def test_init_empty_dir_no_crash(self):
"""空目录不崩溃(不扫描到 .abap 文件则提前返回)。"""
from sapcli.commands.batch import cmd_init
client = _make_client()
with tempfile.TemporaryDirectory() as td:
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
# 空目录无 .abap 文件 → 不创建 manifest → 不崩溃即可
def test_init_with_abap_file(self):
"""有 .abap 文件时,初始化应创建 manifest。"""
from sapcli.commands.batch import cmd_init
client = _make_client()
client.get_object_status.return_value = {"exists": True, "status": "active", "corr_nr": None}
with tempfile.TemporaryDirectory() as td:
report_dir = os.path.join(td, "reports")
os.makedirs(report_dir)
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
f.write("REPORT ztest.")
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json")))
class TestCmdRefresh(unittest.TestCase):
def test_refresh_success(self):
from sapcli.commands.batch import cmd_refresh
from sapcli.manifest import Manifest, ManifestEntry
client = _make_client()
client.get_object_status.return_value = {
"exists": True, "status": "active", "corr_nr": None
}
with tempfile.TemporaryDirectory() as td:
# 直接写 manifest.json 文件,供 Manifest.load() 读取
entry = ManifestEntry(
name="ZTEST", type="report",
file="reports/ztest.abap",
system_status="not_exists",
)
import json
manifest_data = {
"version": 2,
"objects": {"ZTEST": entry.to_dict()},
}
manifest_path = os.path.join(td, "manifest.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest_data, f)
os.makedirs(os.path.join(td, "reports"))
cmd_refresh(argparse.Namespace(path=td, config=None), client)
client.get_object_status.assert_called()
# ═══════════════════════════════════════════
# cds.py
# ═══════════════════════════════════════════
class TestCmdCds(unittest.TestCase):
def test_cds_download(self):
from sapcli.commands.cds import cmd_cds
client = _make_client()
client.get_cds_source.return_value = "define view Z_TEST as select from mara { mara.matnr };"
with tempfile.TemporaryDirectory() as td:
cmd_cds(argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), client)
client.get_cds_source.assert_called_once()
def test_cds_create(self):
from sapcli.commands.cds import cmd_cds
client = _make_client()
client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main")
with tempfile.TemporaryDirectory() as td:
cmd_cds(argparse.Namespace(
cds_action="create", name="Z_TEST", description="Test CDS",
source=None, path=td, config=None
), client)
client.create_cds.assert_called_once()
# ═══════════════════════════════════════════
# config_cmd.py
# ═══════════════════════════════════════════
class TestCmdConfig(unittest.TestCase):
def test_config_show(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_show"):
cmd_config(argparse.Namespace(config_action="show", config=None))
def test_config_list_profiles(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_list_profiles"):
cmd_config(argparse.Namespace(config_action="list-profiles", config=None))
def test_config_set(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_set"):
cmd_config(argparse.Namespace(config_action="set", key="host", value="http://new:8000", config=None))
# ═══════════════════════════════════════════
# diff_cmd.py
# ═══════════════════════════════════════════
class TestCmdDiff(unittest.TestCase):
def test_diff_success(self):
from sapcli.commands.diff_cmd import cmd_diff
client = _make_client()
client.read_source_for_diff.return_value = "REPORT zremote."
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT zlocal.")
tmpfile = f.name
try:
cmd_diff(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
client.read_source_for_diff.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# package_cmd.py
# ═══════════════════════════════════════════
class TestCmdPackage(unittest.TestCase):
def test_package_info(self):
from sapcli.commands.package_cmd import cmd_package
client = _make_client()
client.get_package_info.return_value = {"name": "Z_TEST", "description": "Test"}
cmd_package(argparse.Namespace(package_action="info", name="Z_TEST", config=None), client)
client.get_package_info.assert_called_once()
def test_package_create(self):
from sapcli.commands.package_cmd import cmd_package
client = _make_client()
client.create_package.return_value = True
cmd_package(argparse.Namespace(package_action="create", name="Z_TEST_PKG", description="Test", superpackage=None, config=None), client)
client.create_package.assert_called_once()
# ═══════════════════════════════════════════
# quality.py
# ═══════════════════════════════════════════
class TestCmdCheck(unittest.TestCase):
def test_check_clean(self):
from sapcli.commands.quality import cmd_check
client = _make_client()
client.atc_check.return_value = (True, [])
cmd_check(_args(), client)
client.atc_check.assert_called_once()
def test_check_with_errors(self):
from sapcli.commands.quality import cmd_check
client = _make_client()
client.atc_check.return_value = (False, [{"type": "E", "line": "1", "text": "Error"}])
cmd_check(_args(), client) # 打印错误但不抛异常
class TestCmdFormat(unittest.TestCase):
def test_format_success(self):
from sapcli.commands.quality import cmd_format
client = _make_client()
client.pretty_print.return_value = "FORMATTED CODE"
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_format(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
client.pretty_print.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# search.py
# ═══════════════════════════════════════════
class TestCmdList(unittest.TestCase):
def test_list_found(self):
from sapcli.commands.search import cmd_list
client = _make_client()
client.list_objects.return_value = [{"name": "Z_TEST", "type": "PROG/P", "package": "$TMP"}]
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
client.list_objects.assert_called_once()
def test_list_empty(self):
from sapcli.commands.search import cmd_list
client = _make_client()
client.list_objects.return_value = []
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
class TestCmdWhereUsed(unittest.TestCase):
def test_whereused_found(self):
from sapcli.commands.search import cmd_whereused
client = _make_client()
client.where_used.return_value = [{"name": "Z_USER", "type": "PROG/P"}]
cmd_whereused(_args(), client)
client.where_used.assert_called_once()
class TestCmdSearch(unittest.TestCase):
def test_search_found(self):
from sapcli.commands.search import cmd_search
client = _make_client()
client.search_code.return_value = [{"name": "Z_RESULT", "type": "PROG/P"}]
cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client)
client.search_code.assert_called_once()
# ═══════════════════════════════════════════
# transport.py
# ═══════════════════════════════════════════
class TestCmdTransport(unittest.TestCase):
def test_transport_list(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.list_transport_requests.return_value = [
{"number": "DEVK001", "description": "Test", "owner": "TESTUSER", "status": "D"}
]
cmd_transport(argparse.Namespace(transport_action="list", config=None), client)
client.list_transport_requests.assert_called_once()
def test_transport_info(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.transport_info.return_value = {"number": "DEVK001", "description": "Test", "status": "D"}
cmd_transport(argparse.Namespace(transport_action="info", number="DEVK001", corr_nr="DEVK001", config=None), client)
client.transport_info.assert_called_once()
def test_transport_release(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.transport_release.return_value = True
with patch("builtins.input", return_value="yes"):
cmd_transport(argparse.Namespace(transport_action="release", number="DEVK001", corr_nr="DEVK001", config=None), client)
client.transport_release.assert_called_once()
# ═══════════════════════════════════════════
# analyze.py
# ═══════════════════════════════════════════
class TestCmdAnalyze(unittest.TestCase):
def test_analyze_success(self):
from sapcli.commands.analyze import cmd_analyze
client = _make_client()
client.get_source.return_value = "REPORT ztest.\nCALL FUNCTION 'BAPI_TEST'."
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_analyze(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# scaffold.py
# ═══════════════════════════════════════════
class TestCmdScaffold(unittest.TestCase):
def test_scaffold_no_template_lists(self):
from sapcli.commands.scaffold import cmd_scaffold
with patch("builtins.print") as mock_print:
cmd_scaffold(argparse.Namespace(template=None, name="ZTEST", path=".", config=None, package="$TMP"), None)
self.assertTrue(mock_print.called)
def test_scaffold_alv_report(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="alv-report", name="ZALV_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_bapi_wrapper(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="bapi-wrapper", name="ZBAPI_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_interface_class(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="interface-class", name="ZIF_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_data_model(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="data-model", name="ZMODEL", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
# ═══════════════════════════════════════════
# auth.py — 需 mock getpass + load_config
# ═══════════════════════════════════════════
class TestAuth(unittest.TestCase):
@patch("sapcli.auth.getpass")
@patch("sapcli.auth.set_password", return_value=True)
@patch("sapcli.config.load_config")
def test_cmd_auth_login(self, mock_load, mock_set, mock_getpass):
from sapcli.auth import cmd_auth_login
mock_cfg = MagicMock()
mock_cfg.host = "http://sap:8000"
mock_cfg.client = "100"
mock_cfg.user = "TESTUSER"
mock_load.return_value = (mock_cfg, None)
mock_getpass.getpass.return_value = "SECRET"
cmd_auth_login(argparse.Namespace(config=None))
mock_set.assert_called_once()
def test_cmd_auth_status_no_keyring(self):
from sapcli.auth import cmd_auth_status
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
cmd_auth_status(argparse.Namespace()) # 不抛异常
if __name__ == "__main__":
unittest.main(verbosity=2)