浏览代码

test(ds): 加 ds 模块单元测试 + cli.main 接受 argv 参数

tests/unit/ds/ 单测覆盖 api.py JSON fail-fast / 错误码 / config
缺失 / token header / URL 拼接 / text 截断;cli.py -o 写文件 / 子
命令分发 / -p 容错。cli.main 接 argv 与 datax/cli.py 风格对齐。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tianyu.chu 5 天之前
父节点
当前提交
22bc6271b7
共有 4 个文件被更改,包括 207 次插入2 次删除
  1. 2 2
      dw_base/ds/cli.py
  2. 0 0
      tests/unit/ds/__init__.py
  3. 123 0
      tests/unit/ds/test_api.py
  4. 82 0
      tests/unit/ds/test_cli.py

+ 2 - 2
dw_base/ds/cli.py

@@ -28,7 +28,7 @@ def _parse_kv(items):
     return d
 
 
-def main():
+def main(argv=None):
     parser = argparse.ArgumentParser(prog='dw_base.ds.cli', description='DolphinScheduler API CLI')
     sub = parser.add_subparsers(dest='cmd')
     sub.required = True
@@ -43,7 +43,7 @@ def main():
     p.add_argument('-j', dest='body', default=None, metavar='JSON', help='JSON body')
     p.add_argument('-o', dest='output', default=None, metavar='FILE', help='写 JSON 到文件(不打 stdout)')
 
-    args = parser.parse_args()
+    args = parser.parse_args(argv)
     client = DSClient()
 
     if args.cmd == 'get':

+ 0 - 0
tests/unit/ds/__init__.py


+ 123 - 0
tests/unit/ds/test_api.py

@@ -0,0 +1,123 @@
+# -*- coding:utf-8 -*-
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from dw_base.ds.api import DSClient
+
+
+@pytest.fixture
+def client(tmp_path):
+    conf = tmp_path / 'ds.ini'
+    conf.write_text(
+        '[dolphinscheduler]\n'
+        'base_url = http://example/dolphinscheduler\n'
+        'token = TEST_TOKEN\n',
+        encoding='utf-8',
+    )
+    return DSClient(conf_path=str(conf))
+
+
+def test_init_loads_conf(client):
+    assert client.base_url == 'http://example/dolphinscheduler'
+    assert client.token == 'TEST_TOKEN'
+    assert client.session.headers['token'] == 'TEST_TOKEN'
+
+
+def test_init_missing_conf_raises():
+    with pytest.raises(RuntimeError, match='DS 配置文件不存在'):
+        DSClient(conf_path='/nonexistent/path.ini')
+
+
+def test_init_strips_trailing_slash(tmp_path):
+    conf = tmp_path / 'ds.ini'
+    conf.write_text(
+        '[dolphinscheduler]\n'
+        'base_url = http://example/dolphinscheduler/\n'
+        'token = T\n',
+        encoding='utf-8',
+    )
+    c = DSClient(conf_path=str(conf))
+    assert c.base_url == 'http://example/dolphinscheduler'
+
+
+def test_get_2xx_returns_json(client):
+    with patch.object(client.session, 'get') as mock_get:
+        resp = MagicMock(status_code=200)
+        resp.json.return_value = {'code': 0, 'data': 'ok'}
+        mock_get.return_value = resp
+        assert client.get('/projects') == {'code': 0, 'data': 'ok'}
+        mock_get.assert_called_once_with(
+            'http://example/dolphinscheduler/projects', params=None)
+
+
+def test_get_strips_leading_slash(client):
+    """path 带或不带前导 / 都拼成相同 URL。"""
+    with patch.object(client.session, 'get') as mock_get:
+        resp = MagicMock(status_code=200)
+        resp.json.return_value = {}
+        mock_get.return_value = resp
+        client.get('projects')
+        assert mock_get.call_args[0][0] == 'http://example/dolphinscheduler/projects'
+
+
+def test_get_non_2xx_raises(client):
+    with patch.object(client.session, 'get') as mock_get:
+        mock_get.return_value = MagicMock(status_code=500, text='Internal Server Error')
+        with pytest.raises(RuntimeError, match='GET .*failed rc=500'):
+            client.get('/projects')
+
+
+def test_get_2xx_non_json_raises_friendly(client):
+    """新特性:path 错被 SPA fallback 时抛 RuntimeError 含 raw text 前 200 字符。"""
+    with patch.object(client.session, 'get') as mock_get:
+        resp = MagicMock(status_code=200)
+        resp.json.side_effect = ValueError('Expecting value')
+        resp.text = '<!DOCTYPE html><html>fallback</html>'
+        mock_get.return_value = resp
+        with pytest.raises(RuntimeError) as exc:
+            client.get('/wrong/path')
+        msg = str(exc.value)
+        assert '返回非 JSON' in msg
+        assert 'SPA fallback' in msg
+        assert '<!DOCTYPE html>' in msg
+
+
+def test_get_2xx_non_json_truncates_to_200(client):
+    with patch.object(client.session, 'get') as mock_get:
+        resp = MagicMock(status_code=200)
+        resp.json.side_effect = ValueError()
+        resp.text = 'X' * 500
+        mock_get.return_value = resp
+        with pytest.raises(RuntimeError) as exc:
+            client.get('/p')
+        # 错误信息含 200 个 X,不含 500 个
+        assert 'X' * 200 in str(exc.value)
+        assert 'X' * 201 not in str(exc.value)
+
+
+def test_post_2xx_returns_json(client):
+    with patch.object(client.session, 'post') as mock_post:
+        resp = MagicMock(status_code=200)
+        resp.json.return_value = {'ok': True}
+        mock_post.return_value = resp
+        assert client.post('/foo', json_body={'k': 'v'}) == {'ok': True}
+        mock_post.assert_called_once_with(
+            'http://example/dolphinscheduler/foo', json={'k': 'v'})
+
+
+def test_post_2xx_non_json_raises_friendly(client):
+    with patch.object(client.session, 'post') as mock_post:
+        resp = MagicMock(status_code=200)
+        resp.json.side_effect = ValueError()
+        resp.text = 'NOT JSON'
+        mock_post.return_value = resp
+        with pytest.raises(RuntimeError, match='POST.*返回非 JSON'):
+            client.post('/foo')
+
+
+def test_post_non_2xx_raises(client):
+    with patch.object(client.session, 'post') as mock_post:
+        mock_post.return_value = MagicMock(status_code=403, text='Forbidden')
+        with pytest.raises(RuntimeError, match='POST .*failed rc=403'):
+            client.post('/foo')

+ 82 - 0
tests/unit/ds/test_cli.py

@@ -0,0 +1,82 @@
+# -*- coding:utf-8 -*-
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from dw_base.ds import cli
+
+
+@patch('dw_base.ds.cli.DSClient')
+def test_get_invokes_client_get(MockClient, capsys):
+    inst = MagicMock()
+    inst.get.return_value = {'code': 0}
+    MockClient.return_value = inst
+
+    cli.main(['get', '/projects', '-p', 'pageNo=1', '-p', 'pageSize=10'])
+
+    inst.get.assert_called_once_with(
+        '/projects', params={'pageNo': '1', 'pageSize': '10'})
+    out = capsys.readouterr().out
+    assert json.loads(out) == {'code': 0}
+
+
+@patch('dw_base.ds.cli.DSClient')
+def test_get_o_writes_file(MockClient, tmp_path, capsys):
+    """新特性:-o 把 JSON 写文件,stdout 不打,stderr 提示落点。"""
+    inst = MagicMock()
+    inst.get.return_value = {'code': 0, 'data': 'x'}
+    MockClient.return_value = inst
+
+    out_file = tmp_path / 'out.json'
+    cli.main(['get', '/projects', '-o', str(out_file)])
+
+    assert json.loads(out_file.read_text(encoding='utf-8')) == {'code': 0, 'data': 'x'}
+    captured = capsys.readouterr()
+    assert captured.out == ''
+    assert '已写到' in captured.err
+    assert str(out_file) in captured.err
+
+
+@patch('dw_base.ds.cli.DSClient')
+def test_post_with_json_body(MockClient, capsys):
+    inst = MagicMock()
+    inst.post.return_value = {'ok': True}
+    MockClient.return_value = inst
+
+    cli.main(['post', '/foo', '-j', '{"a":1,"b":"x"}'])
+
+    inst.post.assert_called_once_with('/foo', json_body={'a': 1, 'b': 'x'})
+
+
+@patch('dw_base.ds.cli.DSClient')
+def test_post_no_body(MockClient, capsys):
+    inst = MagicMock()
+    inst.post.return_value = {}
+    MockClient.return_value = inst
+
+    cli.main(['post', '/foo'])
+
+    inst.post.assert_called_once_with('/foo', json_body=None)
+
+
+def test_no_subcommand_exits():
+    with pytest.raises(SystemExit):
+        cli.main([])
+
+
+def test_unknown_subcommand_exits():
+    with pytest.raises(SystemExit):
+        cli.main(['bogus'])
+
+
+def test_kv_invalid_format_skipped(capsys):
+    """-p 参数无 = 时跳过并 stderr 警告,不 raise。"""
+    with patch('dw_base.ds.cli.DSClient') as MockClient:
+        inst = MagicMock()
+        inst.get.return_value = {}
+        MockClient.return_value = inst
+
+        cli.main(['get', '/p', '-p', 'no_equals', '-p', 'k=v'])
+        inst.get.assert_called_once_with('/p', params={'k': 'v'})
+        assert '需要 k=v 格式' in capsys.readouterr().err