test_sync_template_gen.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. # -*- coding:utf-8 -*-
  2. """
  3. datax-sync-template-gen 模板渲染 + JDBC URL 解析单测。
  4. 不连真 PG(query_columns_full 走 mock conn)。
  5. 脚本路径含连字符,用 importlib.util 动态加载为模块。
  6. """
  7. import importlib.util
  8. import os
  9. import sys
  10. from unittest.mock import MagicMock
  11. import pytest
  12. PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
  13. SCRIPT_PATH = os.path.join(PROJECT_ROOT, 'bin', 'datax-sync-template-gen.py')
  14. def _load_script():
  15. spec = importlib.util.spec_from_file_location('datax_sync_template_gen', SCRIPT_PATH)
  16. mod = importlib.util.module_from_spec(spec)
  17. sys.modules['datax_sync_template_gen'] = mod
  18. spec.loader.exec_module(mod)
  19. return mod
  20. GEN = _load_script()
  21. def test_render_template_includes_required_fields():
  22. columns = [('id', 'id'), ('name', '姓名'), ('create_time', '创建时间')]
  23. out = GEN.render_template(
  24. ds_ref='postgresql/prod-hobby',
  25. database='hobby_stocks',
  26. schema='public',
  27. table='users',
  28. columns=columns,
  29. pk='id',
  30. )
  31. assert 'dataSource = postgresql/prod-hobby' in out
  32. assert 'database = hobby_stocks' in out
  33. assert 'table = public.users' in out
  34. assert 'column = id,name,create_time' in out
  35. assert 'splitPk = id' in out
  36. assert "where = update_time >= '${start_date}' AND update_time < '${stop_date}'" in out
  37. assert 'path = /user/hive/warehouse/raw.db/users_TODO_d/dt=${dt}/' in out
  38. assert 'fileName = users_TODO_d' in out
  39. # 不传 mask_methods 时不渲染 [mask] section header
  40. assert '\n[mask]\n' not in out
  41. def test_render_template_with_mask_methods():
  42. columns = [('id', 'id'), ('user_name', '用户名'), ('phone', '手机号')]
  43. out = GEN.render_template(
  44. ds_ref='postgresql/prod-hobby', database='db', schema='public',
  45. table='users', columns=columns, pk='id',
  46. mask_methods={'user_name': 'mask_middle', 'phone': 'md5'},
  47. )
  48. # [mask] section header 在 [reader] 后 [writer] 前
  49. assert '\n[mask]\n' in out
  50. assert 'user_name = mask_middle' in out
  51. assert 'phone = md5' in out
  52. reader_idx = out.index('\n[reader]\n')
  53. mask_idx = out.index('\n[mask]\n')
  54. writer_idx = out.index('\n[writer]\n')
  55. assert reader_idx < mask_idx < writer_idx
  56. def test_query_columns_full_returns_full_metadata():
  57. conn = MagicMock()
  58. cur = conn.cursor.return_value
  59. cur.fetchall.return_value = [
  60. (1, 'id', 'id', 'bigint', 'PK'),
  61. (2, 'name', '名称', 'character varying', ''),
  62. ]
  63. rows = GEN.query_columns_full(conn, 'public', 'orders')
  64. assert rows == [
  65. (1, 'id', 'id', 'bigint', 'PK'),
  66. (2, 'name', '名称', 'character varying', ''),
  67. ]
  68. def test_render_schema_md_no_mask_dict_blank_column():
  69. rows = [
  70. (1, 'id', 'id', 'bigint', 'PK'),
  71. (2, 'user_name', '用户名', 'character varying', ''),
  72. (3, 'create_time', None, 'timestamp without time zone', ''),
  73. ]
  74. out = GEN.render_schema_md(rows)
  75. assert '| 序号 | 字段名 | 中文名 | 数据类型 | 主键标识 | 脱敏类型 |' in out
  76. assert '| 1 | `id` | id | bigint | PK | |' in out
  77. assert '| 2 | `user_name` | 用户名 | character varying | | |' in out
  78. assert '| 3 | `create_time` | | timestamp without time zone | | |' in out
  79. def test_render_schema_md_with_mask_dict():
  80. rows = [
  81. (1, 'id', 'id', 'bigint', 'PK'),
  82. (2, 'user_name', '用户名', 'character varying', ''),
  83. (3, 'phone', '手机号', 'character varying', ''),
  84. (4, 'merchant_open', '商家代开', 'smallint', ''),
  85. ]
  86. mask_dict = {'phone': 'md5', 'merchant_open': 'trim', 'user_name': 'mask_middle'}
  87. out = GEN.render_schema_md(rows, mask_dict)
  88. assert '| 1 | `id` | id | bigint | PK | |' in out
  89. assert '| 2 | `user_name` | 用户名 | character varying | | mask_middle |' in out
  90. assert '| 3 | `phone` | 手机号 | character varying | | md5 |' in out
  91. assert '| 4 | `merchant_open` | 商家代开 | smallint | | trim |' in out
  92. def test_load_mask_conf_basic(tmp_path):
  93. p = tmp_path / 't.mask.ini'
  94. p.write_text(
  95. '[mask]\n'
  96. 'payment_num = trim\n'
  97. 'phone = md5\n'
  98. 'name = mask_middle\n',
  99. encoding='utf-8',
  100. )
  101. assert GEN.load_mask_conf(str(p)) == {
  102. 'payment_num': 'trim',
  103. 'phone': 'md5',
  104. 'name': 'mask_middle',
  105. }
  106. def test_load_mask_conf_no_section_returns_empty(tmp_path):
  107. p = tmp_path / 't.mask.ini'
  108. p.write_text('[other]\nfoo = bar\n', encoding='utf-8')
  109. assert GEN.load_mask_conf(str(p)) == {}
  110. def test_load_mask_conf_missing_file_raises():
  111. with pytest.raises(FileNotFoundError, match='mask 配置不存在'):
  112. GEN.load_mask_conf('/nonexistent/path/x.mask.ini')
  113. def test_resolve_to_project_root_absolute_pass_through():
  114. abs_path = os.path.abspath('/abs/path/x.ini')
  115. assert GEN._resolve_to_project_root(abs_path) == abs_path
  116. def test_resolve_to_project_root_relative_joins_project_root():
  117. result = GEN._resolve_to_project_root('jobs/raw/trd/x.mask.ini')
  118. assert os.path.isabs(result)
  119. assert result.endswith('jobs/raw/trd/x.mask.ini')
  120. def test_render_template_empty_pk():
  121. out = GEN.render_template(
  122. ds_ref='postgresql/prod-hobby', database='db', schema='public',
  123. table='t', columns=[('a', '')], pk='',
  124. )
  125. assert 'splitPk = \n' in out
  126. def _patch_main_dependencies(monkeypatch):
  127. """共享 mock:让 main() 不连真 PG / 真 datasource。"""
  128. fake_conn = MagicMock()
  129. fake_cur = fake_conn.cursor.return_value
  130. fake_cur.fetchall.return_value = [
  131. (1, 'id', 'id', 'bigint', 'PK'),
  132. (2, 'name', '名称', 'character varying', ''),
  133. ]
  134. monkeypatch.setattr(GEN.pgdb, 'resolve', lambda ref: {
  135. 'host': '10.0.0.1', 'port': 5432, 'database': 'mydb',
  136. 'username': 'u', 'password': 'p',
  137. })
  138. monkeypatch.setattr(GEN.pgdb, 'connect', lambda ref: fake_conn)
  139. def test_main_stdout_only_when_no_o(monkeypatch, capsys):
  140. _patch_main_dependencies(monkeypatch)
  141. monkeypatch.setattr(sys, 'argv', [
  142. 'datax-sync-template-gen.py',
  143. '-ds', 'postgresql/prod-hobby',
  144. '-t', 'public.users',
  145. ])
  146. GEN.main()
  147. captured = capsys.readouterr()
  148. assert '| 序号 | 字段名 |' in captured.out
  149. assert '[reader]' in captured.out
  150. assert '已写入' not in captured.err
  151. def test_main_stdout_and_disk_when_o_with_dir(monkeypatch, capsys, tmp_path):
  152. _patch_main_dependencies(monkeypatch)
  153. out_dir = tmp_path / 'out'
  154. monkeypatch.setattr(sys, 'argv', [
  155. 'datax-sync-template-gen.py',
  156. '-ds', 'postgresql/prod-hobby',
  157. '-t', 'public.users',
  158. '-o', str(out_dir),
  159. ])
  160. GEN.main()
  161. captured = capsys.readouterr()
  162. assert '| 序号 | 字段名 |' in captured.out
  163. assert '[reader]' in captured.out
  164. assert '已写入' in captured.err
  165. assert (out_dir / 'users.md').exists()
  166. assert (out_dir / 'users.ini').exists()
  167. def test_main_stdout_and_disk_when_o_no_value(monkeypatch, capsys, tmp_path):
  168. _patch_main_dependencies(monkeypatch)
  169. monkeypatch.setattr(GEN, 'WORKSPACE_DEFAULT', str(tmp_path / 'workspace'))
  170. monkeypatch.setattr(sys, 'argv', [
  171. 'datax-sync-template-gen.py',
  172. '-ds', 'postgresql/prod-hobby',
  173. '-t', 'public.users',
  174. '-o',
  175. ])
  176. GEN.main()
  177. captured = capsys.readouterr()
  178. assert '| 序号 | 字段名 |' in captured.out
  179. assert '[reader]' in captured.out
  180. assert '已写入' in captured.err