test_api.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. # -*- coding:utf-8 -*-
  2. from unittest.mock import MagicMock, patch
  3. import pytest
  4. from dw_base.ds.api import DSClient
  5. @pytest.fixture
  6. def client(tmp_path):
  7. conf = tmp_path / 'ds.ini'
  8. conf.write_text(
  9. '[dolphinscheduler]\n'
  10. 'base_url = http://example/dolphinscheduler\n'
  11. 'token = TEST_TOKEN\n',
  12. encoding='utf-8',
  13. )
  14. return DSClient(conf_path=str(conf))
  15. def _mock_resp(status_code=200, json_data=None, text=''):
  16. """构造 requests.Response mock。json_data=None 表示 .json() 抛 ValueError。"""
  17. r = MagicMock(status_code=status_code, text=text)
  18. if json_data is None:
  19. r.json.side_effect = ValueError('not json')
  20. else:
  21. r.json.return_value = json_data
  22. return r
  23. # --- 初始化 ---
  24. def test_init_loads_conf(client):
  25. assert client.base_url == 'http://example/dolphinscheduler'
  26. assert client.token == 'TEST_TOKEN'
  27. assert client.session.headers['token'] == 'TEST_TOKEN'
  28. def test_init_missing_conf_raises():
  29. with pytest.raises(RuntimeError, match='DS 配置文件不存在'):
  30. DSClient(conf_path='/nonexistent/path.ini')
  31. def test_init_strips_trailing_slash(tmp_path):
  32. conf = tmp_path / 'ds.ini'
  33. conf.write_text(
  34. '[dolphinscheduler]\n'
  35. 'base_url = http://example/dolphinscheduler/\n'
  36. 'token = T\n',
  37. encoding='utf-8',
  38. )
  39. c = DSClient(conf_path=str(conf))
  40. assert c.base_url == 'http://example/dolphinscheduler'
  41. # --- GET ---
  42. def test_get_2xx_returns_json(client):
  43. with patch.object(client.session, 'request') as mock_req:
  44. mock_req.return_value = _mock_resp(200, {'code': 0, 'data': 'ok'})
  45. assert client.get('/projects') == {'code': 0, 'data': 'ok'}
  46. # 不传 params 时不应在 kwargs 出现 params
  47. mock_req.assert_called_once_with(
  48. 'GET', 'http://example/dolphinscheduler/projects')
  49. def test_get_with_params(client):
  50. with patch.object(client.session, 'request') as mock_req:
  51. mock_req.return_value = _mock_resp(200, {})
  52. client.get('/projects', params={'pageNo': 1, 'pageSize': 10})
  53. mock_req.assert_called_once_with(
  54. 'GET', 'http://example/dolphinscheduler/projects',
  55. params={'pageNo': 1, 'pageSize': 10})
  56. def test_get_strips_leading_slash(client):
  57. """path 带或不带前导 / 都拼成相同 URL。"""
  58. with patch.object(client.session, 'request') as mock_req:
  59. mock_req.return_value = _mock_resp(200, {})
  60. client.get('projects')
  61. assert mock_req.call_args[0][1] == 'http://example/dolphinscheduler/projects'
  62. def test_get_non_2xx_raises(client):
  63. with patch.object(client.session, 'request') as mock_req:
  64. mock_req.return_value = _mock_resp(500, json_data={}, text='Internal Server Error')
  65. # 改 status_code 不影响 json mock
  66. mock_req.return_value.status_code = 500
  67. with pytest.raises(RuntimeError, match='GET .*failed rc=500'):
  68. client.get('/projects')
  69. def test_get_2xx_non_json_raises_friendly(client):
  70. """path 错被 SPA fallback 时抛 RuntimeError 含 raw text 前 200 字符。"""
  71. with patch.object(client.session, 'request') as mock_req:
  72. mock_req.return_value = _mock_resp(200, json_data=None,
  73. text='<!DOCTYPE html><html>fallback</html>')
  74. with pytest.raises(RuntimeError) as exc:
  75. client.get('/wrong/path')
  76. msg = str(exc.value)
  77. assert '返回非 JSON' in msg
  78. assert 'SPA fallback' in msg
  79. assert '<!DOCTYPE html>' in msg
  80. def test_get_2xx_non_json_truncates_to_200(client):
  81. with patch.object(client.session, 'request') as mock_req:
  82. mock_req.return_value = _mock_resp(200, json_data=None, text='X' * 500)
  83. with pytest.raises(RuntimeError) as exc:
  84. client.get('/p')
  85. assert 'X' * 200 in str(exc.value)
  86. assert 'X' * 201 not in str(exc.value)
  87. # --- POST ---
  88. def test_post_json_body(client):
  89. with patch.object(client.session, 'request') as mock_req:
  90. mock_req.return_value = _mock_resp(200, {'ok': True})
  91. assert client.post('/foo', json_body={'k': 'v'}) == {'ok': True}
  92. mock_req.assert_called_once_with(
  93. 'POST', 'http://example/dolphinscheduler/foo', json={'k': 'v'})
  94. def test_post_form_data_uses_data_kwarg(client):
  95. """form_data 走 requests data= kwarg(form-encoded)。"""
  96. with patch.object(client.session, 'request') as mock_req:
  97. mock_req.return_value = _mock_resp(200, {'ok': True})
  98. client.post('/foo', form_data={'name': 'x', 'desc': 'hi'})
  99. mock_req.assert_called_once_with(
  100. 'POST', 'http://example/dolphinscheduler/foo',
  101. data={'name': 'x', 'desc': 'hi'})
  102. def test_post_no_body(client):
  103. """不传 body 时 kwargs 不应有 data / json。"""
  104. with patch.object(client.session, 'request') as mock_req:
  105. mock_req.return_value = _mock_resp(200, {})
  106. client.post('/foo')
  107. mock_req.assert_called_once_with(
  108. 'POST', 'http://example/dolphinscheduler/foo')
  109. def test_post_2xx_non_json_raises_friendly(client):
  110. with patch.object(client.session, 'request') as mock_req:
  111. mock_req.return_value = _mock_resp(200, json_data=None, text='NOT JSON')
  112. with pytest.raises(RuntimeError, match='POST.*返回非 JSON'):
  113. client.post('/foo')
  114. def test_post_non_2xx_raises(client):
  115. with patch.object(client.session, 'request') as mock_req:
  116. mock_req.return_value = _mock_resp(403, {}, text='Forbidden')
  117. mock_req.return_value.status_code = 403
  118. with pytest.raises(RuntimeError, match='POST .*failed rc=403'):
  119. client.post('/foo')
  120. def test_post_json_and_form_mutex(client):
  121. with pytest.raises(ValueError, match='互斥'):
  122. client.post('/foo', json_body={'k': 'v'}, form_data={'k': 'v'})
  123. # --- PUT(与 POST 对称) ---
  124. def test_put_json_body(client):
  125. with patch.object(client.session, 'request') as mock_req:
  126. mock_req.return_value = _mock_resp(200, {'ok': True})
  127. assert client.put('/foo/1', json_body={'k': 'v'}) == {'ok': True}
  128. mock_req.assert_called_once_with(
  129. 'PUT', 'http://example/dolphinscheduler/foo/1', json={'k': 'v'})
  130. def test_put_form_data(client):
  131. with patch.object(client.session, 'request') as mock_req:
  132. mock_req.return_value = _mock_resp(200, {'ok': True})
  133. client.put('/foo/1', form_data={'name': 'x'})
  134. mock_req.assert_called_once_with(
  135. 'PUT', 'http://example/dolphinscheduler/foo/1', data={'name': 'x'})
  136. def test_put_non_2xx_raises(client):
  137. with patch.object(client.session, 'request') as mock_req:
  138. mock_req.return_value = _mock_resp(400, {}, text='Bad Request')
  139. mock_req.return_value.status_code = 400
  140. with pytest.raises(RuntimeError, match='PUT .*failed rc=400'):
  141. client.put('/foo/1')
  142. def test_put_2xx_non_json_raises(client):
  143. with patch.object(client.session, 'request') as mock_req:
  144. mock_req.return_value = _mock_resp(200, json_data=None, text='HTML')
  145. with pytest.raises(RuntimeError, match='PUT.*返回非 JSON'):
  146. client.put('/foo/1')
  147. def test_put_json_and_form_mutex(client):
  148. with pytest.raises(ValueError, match='互斥'):
  149. client.put('/foo/1', json_body={'k': 'v'}, form_data={'k': 'v'})
  150. # --- expect_business_ok:业务码 != 0 抛错 ---
  151. def test_business_ok_pass_when_code_zero(client):
  152. with patch.object(client.session, 'request') as mock_req:
  153. mock_req.return_value = _mock_resp(200, {'code': 0, 'data': {'id': 1}})
  154. result = client.post('/foo', form_data={}, expect_business_ok=True)
  155. assert result == {'code': 0, 'data': {'id': 1}}
  156. def test_business_ok_raises_when_code_nonzero(client):
  157. """2xx + code != 0 抛 RuntimeError 含完整 response。"""
  158. with patch.object(client.session, 'request') as mock_req:
  159. mock_req.return_value = _mock_resp(
  160. 200, {'code': 10105, 'msg': "Required request parameter 'name' is not present"})
  161. with pytest.raises(RuntimeError) as exc:
  162. client.post('/foo', form_data={}, expect_business_ok=True)
  163. msg = str(exc.value)
  164. assert 'POST /foo' in msg
  165. assert '业务码 != 0' in msg
  166. assert '10105' in msg
  167. def test_business_ok_off_default_passes_nonzero_code(client):
  168. """expect_business_ok=False(默认)时 code != 0 不抛错,原样返。"""
  169. with patch.object(client.session, 'request') as mock_req:
  170. mock_req.return_value = _mock_resp(200, {'code': 50036, 'msg': 'oops'})
  171. result = client.post('/foo', form_data={})
  172. assert result == {'code': 50036, 'msg': 'oops'}
  173. def test_business_ok_works_for_get(client):
  174. with patch.object(client.session, 'request') as mock_req:
  175. mock_req.return_value = _mock_resp(200, {'code': 1, 'msg': 'fail'})
  176. with pytest.raises(RuntimeError, match='GET .*业务码 != 0'):
  177. client.get('/projects', expect_business_ok=True)
  178. def test_business_ok_works_for_put(client):
  179. with patch.object(client.session, 'request') as mock_req:
  180. mock_req.return_value = _mock_resp(200, {'code': 7, 'msg': 'denied'})
  181. with pytest.raises(RuntimeError, match='PUT .*业务码 != 0'):
  182. client.put('/foo/1', form_data={}, expect_business_ok=True)
  183. def test_business_ok_skips_non_dict_response(client):
  184. """响应不是 dict(罕见,如 list)时 business_ok 不应抛错。"""
  185. with patch.object(client.session, 'request') as mock_req:
  186. mock_req.return_value = _mock_resp(200, [1, 2, 3])
  187. assert client.get('/p', expect_business_ok=True) == [1, 2, 3]