"""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'' b'' ) 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)