test_migration.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. from __future__ import annotations
  2. import importlib.util
  3. from pathlib import Path
  4. from alembic.config import Config
  5. from alembic.script import ScriptDirectory
  6. BACKEND_ROOT = Path(__file__).resolve().parents[2]
  7. MIGRATIONS_ROOT = BACKEND_ROOT / "migrations"
  8. REVISION_PATH = MIGRATIONS_ROOT / "versions" / "0001_initial_schema.py"
  9. RESTORE_REVISION_PATH = (
  10. MIGRATIONS_ROOT / "versions" / "0002_add_restore_audit_action.py"
  11. )
  12. Q3_REVISION_PATH = (
  13. MIGRATIONS_ROOT
  14. / "versions"
  15. / "0003_q3_business_alignment_and_content_search.py"
  16. )
  17. REORDER_REVISION_PATH = (
  18. MIGRATIONS_ROOT / "versions" / "0009_reorder_audit_action.py"
  19. )
  20. ATTACHMENT_TYPE_REVISION_PATH = (
  21. MIGRATIONS_ROOT / "versions" / "0010_attachment_type_redefine.py"
  22. )
  23. REORDER_CATEGORY_REVISION_PATH = (
  24. MIGRATIONS_ROOT
  25. / "versions"
  26. / "0011_reorder_category_audit_action.py"
  27. )
  28. ROLE_MANAGEMENT_REVISION_PATH = (
  29. MIGRATIONS_ROOT / "versions" / "0012_role_management.py"
  30. )
  31. DOCUMENT_PASSWORD_LOCK_REVISION_PATH = (
  32. MIGRATIONS_ROOT / "versions" / "0013_document_password_lock.py"
  33. )
  34. DOCUMENT_RELATIONS_REVISION_PATH = (
  35. MIGRATIONS_ROOT / "versions" / "0014_document_relations.py"
  36. )
  37. LOGIN_IP_REVISION_PATH = (
  38. MIGRATIONS_ROOT / "versions" / "0015_login_ip_restriction.py"
  39. )
  40. PORTAL_USER_PROVISION_REVISION_PATH = (
  41. MIGRATIONS_ROOT / "versions" / "0016_portal_user_provision.py"
  42. )
  43. def _load_revision_module():
  44. spec = importlib.util.spec_from_file_location("dms_initial_migration", REVISION_PATH)
  45. assert spec is not None and spec.loader is not None
  46. module = importlib.util.module_from_spec(spec)
  47. spec.loader.exec_module(module)
  48. return module
  49. def test_alembic_config_and_head_are_valid():
  50. config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
  51. script = ScriptDirectory.from_config(config)
  52. assert script.get_current_head() == "0016_portal_user_provision"
  53. def test_initial_migration_exports_upgrade_and_downgrade():
  54. module = _load_revision_module()
  55. assert callable(module.upgrade)
  56. assert callable(module.downgrade)
  57. def test_initial_migration_contains_seven_tables_and_generated_columns():
  58. source = REVISION_PATH.read_text(encoding="utf-8")
  59. for table_name in (
  60. "sys_organization",
  61. "sys_user",
  62. "doc_category",
  63. "doc_document",
  64. "doc_attachment_binding",
  65. "doc_permission",
  66. "sys_audit_log",
  67. ):
  68. assert f'"{table_name}"' in source
  69. assert source.count('sa.Computed("IF(is_deleted = 0, 1, NULL)"') == 2
  70. assert "uq_doc_attachment_binding_active" in source
  71. assert "uq_doc_permission_active" in source
  72. def test_initial_migration_has_no_cascade():
  73. source = REVISION_PATH.read_text(encoding="utf-8")
  74. assert 'ondelete="CASCADE"' not in source
  75. assert source.count('ondelete="RESTRICT"') >= 8
  76. def test_downgrade_uses_dependency_safe_order():
  77. source = REVISION_PATH.read_text(encoding="utf-8")
  78. positions = [
  79. source.index(f'op.drop_table("{name}")')
  80. for name in (
  81. "sys_audit_log",
  82. "doc_permission",
  83. "doc_attachment_binding",
  84. "doc_document",
  85. "doc_category",
  86. "sys_user",
  87. "sys_organization",
  88. )
  89. ]
  90. assert positions == sorted(positions)
  91. def test_restore_migration_is_scoped_and_reversible():
  92. source = RESTORE_REVISION_PATH.read_text(encoding="utf-8")
  93. assert 'down_revision: str | None = "0001_initial_schema"' in source
  94. assert "RESTORE_DOCUMENT" in source
  95. assert "ix_doc_document_recycle_deleted" in source
  96. assert '["is_deleted", "deleted_at", "id"]' in source
  97. assert "drop_index" in source
  98. assert source.count("create_check_constraint") == 2
  99. assert "create_table" not in source
  100. assert "add_column" not in source
  101. def test_q3_migration_is_single_scoped_revision_with_safe_downgrade():
  102. source = Q3_REVISION_PATH.read_text(encoding="utf-8")
  103. assert (
  104. 'down_revision: str | None = "0002_add_restore_audit_action"'
  105. in source
  106. )
  107. assert "content_text" in source
  108. assert "content_extract_status" in source
  109. assert "content_extracted_at" in source
  110. assert "LONGTEXT" in source
  111. assert "auth_version = auth_version + 1" in source
  112. assert "role_code = 'USER'" in source
  113. assert "role_code IN ('USER', 'ADMIN')" in source
  114. assert "RuntimeError" in source
  115. assert "create_table" not in source
  116. def test_reorder_audit_migration_is_scoped_and_reversible():
  117. source = REORDER_REVISION_PATH.read_text(encoding="utf-8")
  118. assert 'down_revision: str | None = "0008_document_sort_no"' in source
  119. assert "REORDER_DOCUMENT" in source
  120. assert "REORDER_ATTACHMENT" in source
  121. assert "audit_action_type" in source
  122. assert "RuntimeError" in source
  123. assert "create_table" not in source
  124. assert "add_column" not in source
  125. def test_attachment_type_redefine_migration_maps_legacy_values():
  126. source = ATTACHMENT_TYPE_REVISION_PATH.read_text(encoding="utf-8")
  127. assert 'down_revision: str | None = "0009_reorder_audit_action"' in source
  128. assert "SUPPORT_ACHIEVEMENT" in source
  129. assert "TRAINING_MATERIAL" in source
  130. assert "TYPICAL_CASE" in source
  131. assert "document_attachment_type" in source
  132. assert "UPDATE doc_document SET attachment_type = 'SUPPORT_ACHIEVEMENT'" in source
  133. assert "RuntimeError" in source
  134. assert "create_table" not in source
  135. assert "add_column" not in source
  136. def test_reorder_category_audit_migration_is_scoped_and_reversible():
  137. source = REORDER_CATEGORY_REVISION_PATH.read_text(encoding="utf-8")
  138. assert (
  139. 'down_revision: str | None = "0010_attachment_type_redefine"'
  140. in source
  141. )
  142. assert "REORDER_CATEGORY" in source
  143. assert "audit_action_type" in source
  144. assert "RuntimeError" in source
  145. assert "create_table" not in source
  146. assert "add_column" not in source
  147. def test_role_management_migration_is_scoped_and_reversible():
  148. source = ROLE_MANAGEMENT_REVISION_PATH.read_text(encoding="utf-8")
  149. assert (
  150. 'down_revision: str | None = "0011_reorder_category_audit_action"'
  151. in source
  152. )
  153. for table_name in ("sys_role", "sys_role_dept", "sys_role_permission"):
  154. assert f'"{table_name}"' in source
  155. assert "uq_sys_role_default_active" in source
  156. assert "IF(is_default = 1 AND is_deleted = 0, 1, NULL)" in source
  157. assert "can_config_permission" in source
  158. assert "can_bind_attachment" in source
  159. assert "can_unbind_attachment" in source
  160. assert "fk_sys_user_role_id_sys_role" in source
  161. assert "CHANGE_ROLE_PERMISSION" in source
  162. assert "'ROLE'" in source
  163. assert "RuntimeError" in source
  164. assert source.count("op.create_table") == 3
  165. # 降级按依赖序先删矩阵/部门,再删角色本体
  166. positions = [
  167. source.index(f'op.drop_table("{name}")')
  168. for name in ("sys_role_permission", "sys_role_dept", "sys_role")
  169. ]
  170. assert positions == sorted(positions)
  171. def test_document_password_lock_migration_is_scoped_and_reversible():
  172. source = DOCUMENT_PASSWORD_LOCK_REVISION_PATH.read_text(encoding="utf-8")
  173. assert (
  174. 'down_revision: str | None = "0012_role_management"' in source
  175. )
  176. assert '"password_hash"' in source
  177. for column in (
  178. "protect_view",
  179. "protect_download",
  180. "protect_edit",
  181. "protect_delete",
  182. "protect_config_permission",
  183. "protect_bind_attachment",
  184. "protect_unbind_attachment",
  185. ):
  186. assert f'"{column}"' in source
  187. assert "document_password_main_only" in source
  188. assert "(password_hash IS NULL) OR (document_type = 'MAIN')" in source
  189. assert "DOCUMENT_PASSWORD_SET" in source
  190. assert "DOCUMENT_PASSWORD_REMOVE" in source
  191. assert "RuntimeError" in source
  192. assert "create_table" not in source
  193. def test_document_relations_migration_is_scoped_and_reversible():
  194. source = DOCUMENT_RELATIONS_REVISION_PATH.read_text(encoding="utf-8")
  195. assert (
  196. 'down_revision: str | None = "0013_document_password_lock"' in source
  197. )
  198. assert 'op.create_table(\n "doc_document_relation"' in source
  199. assert '"relation_type"' in source
  200. assert "sa.String(length=64)" in source
  201. assert "uq_doc_document_relation_active" in source
  202. assert "IF(is_deleted = 0, 1, NULL)" in source
  203. assert "document_relation_no_self" in source
  204. assert "document_id <> related_document_id" in source
  205. assert source.count("ondelete=\"RESTRICT\"") == 2
  206. # 审计复用EDIT_DOCUMENT,不扩展sys_audit_log的CHECK约束
  207. assert "audit_action_type" not in source
  208. assert "RuntimeError" in source
  209. assert source.count("op.create_table") == 1
  210. assert "add_column" not in source
  211. def test_login_ip_restriction_migration_is_scoped_and_reversible():
  212. source = LOGIN_IP_REVISION_PATH.read_text(encoding="utf-8")
  213. assert (
  214. 'down_revision: str | None = "0014_document_relations"' in source
  215. )
  216. for table_name in ("sys_ip_entry", "sys_org_ip_assignment"):
  217. assert f'"{table_name}"' in source
  218. assert '"ip_value"' in source
  219. assert "uq_sys_ip_entry_ip_value_active" in source
  220. assert "uq_sys_org_ip_assignment_active" in source
  221. assert "IF(is_deleted = 0, 1, NULL)" in source
  222. assert "fk_sys_org_ip_assignment_organization_id_sys_organization" in source
  223. assert "fk_sys_org_ip_assignment_ip_entry_id_sys_ip_entry" in source
  224. assert "CREATE_IP_ENTRY" in source
  225. assert "ASSIGN_ORG_IP" in source
  226. assert '"IP_ENTRY"' in source
  227. assert "RuntimeError" in source
  228. assert source.count("op.create_table") == 2
  229. # 降级按依赖序先删分配表,再删IP条目表
  230. positions = [
  231. source.index(f'op.drop_table("{name}")')
  232. for name in ("sys_org_ip_assignment", "sys_ip_entry")
  233. ]
  234. assert positions == sorted(positions)
  235. def test_portal_user_provision_migration_is_scoped_and_reversible():
  236. source = PORTAL_USER_PROVISION_REVISION_PATH.read_text(encoding="utf-8")
  237. assert (
  238. 'down_revision: str | None = "0015_login_ip_restriction"' in source
  239. )
  240. assert '"external_id"' in source
  241. assert "sa.String(length=128)" in source
  242. assert "uq_sys_user_external_id_active" in source
  243. assert "ix_sys_user_external_active" in source
  244. assert "IF(is_deleted = 0, 1, NULL)" in source
  245. # 降级保护:存在门户用户时拒绝有损降级
  246. assert "external_id IS NOT NULL" in source
  247. assert "RuntimeError" in source
  248. assert "create_table" not in source