import re import unittest from collections import Counter from collections.abc import Callable from pathlib import Path SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' INITIALIZERS = ('shierfeng-fastapi.sql',) PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql' MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql' BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql' TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql' SYS_MENU_VALUE_COUNT = 20 SYS_MENU_PERMS_INDEX = 13 SYS_MENU_ICON_INDEX = 14 REMOVED_AI_DICT_CODES = set(range(33, 70)) EXPECTED_TEST_BATCH_TABLES = {'biz_test_batch', 'biz_test_batch_user'} TEST_BATCH_OWNED_TABLES = { 'biz_strain', 'biz_customer', 'biz_customer_site', 'biz_production_line', 'biz_warehouse', 'biz_pallet', 'biz_inbound_order', 'biz_inbound_item', 'biz_filling_record', 'biz_shipment_order', 'biz_shipment_item', 'biz_usage_record', 'biz_return_order', 'biz_return_item', 'biz_pallet_inventory', 'biz_pallet_event', } EXPECTED_TABLES = { 'biz_strain', 'biz_warehouse', 'biz_customer', 'biz_customer_site', 'biz_production_line', 'biz_pallet', 'biz_user_warehouse', 'biz_user_site', 'biz_inbound_order', 'biz_inbound_item', 'biz_filling_record', 'biz_shipment_order', 'biz_shipment_item', 'biz_usage_record', 'biz_return_order', 'biz_return_item', 'biz_pallet_inventory', 'biz_pallet_event', } AUDIT_COLUMNS = {'create_by', 'create_time', 'update_by', 'update_time', 'remark'} CREATE_AUDIT_COLUMNS = {'create_by', 'create_time'} EXPECTED_COLUMNS = { 'biz_strain': {'strain_id', 'strain_code', 'strain_name', 'status'} | AUDIT_COLUMNS, 'biz_warehouse': {'warehouse_id', 'warehouse_code', 'warehouse_name', 'address', 'status'} | AUDIT_COLUMNS, 'biz_customer': {'customer_id', 'customer_code', 'customer_name', 'status'} | AUDIT_COLUMNS, 'biz_customer_site': {'site_id', 'customer_id', 'site_code', 'site_name', 'address', 'status'} | AUDIT_COLUMNS, 'biz_production_line': {'line_id', 'site_id', 'line_code', 'line_name', 'status'} | AUDIT_COLUMNS, 'biz_pallet': {'pallet_id', 'pallet_code', 'qr_token', 'capacity_packages', 'asset_status'} | AUDIT_COLUMNS, 'biz_user_warehouse': {'user_id', 'warehouse_id'} | CREATE_AUDIT_COLUMNS, 'biz_user_site': {'user_id', 'site_id'} | CREATE_AUDIT_COLUMNS, 'biz_inbound_order': { 'inbound_id', 'inbound_no', 'warehouse_id', 'status', 'confirm_idempotency_key', 'confirm_request_hash', 'confirmed_by', 'confirmed_at', } | AUDIT_COLUMNS, 'biz_inbound_item': {'inbound_item_id', 'inbound_id', 'pallet_id'} | CREATE_AUDIT_COLUMNS, 'biz_filling_record': { 'filling_id', 'filling_no', 'pallet_id', 'warehouse_id', 'strain_id', 'strain_code_snapshot', 'strain_name_snapshot', 'batch_no', 'package_spec', 'initial_packages', 'active_slot', 'closed_at', 'idempotency_key', 'request_hash', 'operator_id', 'operated_at', } | AUDIT_COLUMNS, 'biz_shipment_order': { 'shipment_id', 'shipment_no', 'source_warehouse_id', 'target_site_id', 'status', 'confirm_idempotency_key', 'confirm_request_hash', 'confirmed_by', 'confirmed_at', } | AUDIT_COLUMNS, 'biz_shipment_item': { 'shipment_item_id', 'shipment_id', 'pallet_id', 'active_filling_id', 'package_qty', } | CREATE_AUDIT_COLUMNS, 'biz_usage_record': { 'usage_id', 'usage_no', 'pallet_id', 'active_filling_id', 'site_id', 'line_id', 'used_packages', 'remaining_packages_after', 'idempotency_key', 'request_hash', 'operator_id', 'occurred_at', }, 'biz_return_order': { 'return_id', 'return_no', 'source_site_id', 'target_warehouse_id', 'status', 'request_idempotency_key', 'request_hash', 'confirm_idempotency_key', 'confirm_request_hash', 'requested_by', 'requested_at', 'confirmed_by', 'confirmed_at', } | AUDIT_COLUMNS, 'biz_return_item': { 'return_item_id', 'return_id', 'pallet_id', 'active_filling_id_snapshot', 'remaining_packages_snapshot', } | CREATE_AUDIT_COLUMNS, 'biz_pallet_inventory': { 'pallet_id', 'active_filling_id', 'warehouse_id', 'site_id', 'current_line_id', 'flow_state', 'remaining_packages', 'last_event_id', 'version', 'updated_at', }, 'biz_pallet_event': { 'event_id', 'pallet_id', 'event_seq', 'event_type', 'source_type', 'source_id', 'before_state', 'after_state', 'before_warehouse_id', 'before_site_id', 'before_line_id', 'after_warehouse_id', 'after_site_id', 'after_line_id', 'before_remaining_packages', 'after_remaining_packages', 'operator_id', 'occurred_at', 'idempotency_key', 'request_hash', }, } EXPECTED_DATETIME_COLUMNS = { 'biz_strain': {'create_time', 'update_time'}, 'biz_warehouse': {'create_time', 'update_time'}, 'biz_customer': {'create_time', 'update_time'}, 'biz_customer_site': {'create_time', 'update_time'}, 'biz_production_line': {'create_time', 'update_time'}, 'biz_pallet': {'create_time', 'update_time'}, 'biz_user_warehouse': {'create_time'}, 'biz_user_site': {'create_time'}, 'biz_inbound_order': {'confirmed_at', 'create_time', 'update_time'}, 'biz_inbound_item': {'create_time'}, 'biz_filling_record': {'closed_at', 'operated_at', 'create_time', 'update_time'}, 'biz_shipment_order': {'confirmed_at', 'create_time', 'update_time'}, 'biz_shipment_item': {'create_time'}, 'biz_usage_record': {'occurred_at'}, 'biz_return_order': {'requested_at', 'confirmed_at', 'create_time', 'update_time'}, 'biz_return_item': {'create_time'}, 'biz_pallet_inventory': {'updated_at'}, 'biz_pallet_event': {'occurred_at'}, } EXPECTED_PRIMARY_KEYS = { 'biz_strain': ('strain_id',), 'biz_warehouse': ('warehouse_id',), 'biz_customer': ('customer_id',), 'biz_customer_site': ('site_id',), 'biz_production_line': ('line_id',), 'biz_pallet': ('pallet_id',), 'biz_user_warehouse': ('user_id', 'warehouse_id'), 'biz_user_site': ('user_id', 'site_id'), 'biz_inbound_order': ('inbound_id',), 'biz_inbound_item': ('inbound_item_id',), 'biz_filling_record': ('filling_id',), 'biz_shipment_order': ('shipment_id',), 'biz_shipment_item': ('shipment_item_id',), 'biz_usage_record': ('usage_id',), 'biz_return_order': ('return_id',), 'biz_return_item': ('return_item_id',), 'biz_pallet_inventory': ('pallet_id',), 'biz_pallet_event': ('event_id',), } EXPECTED_UNIQUE_KEYS = { 'biz_strain': {('strain_code',)}, 'biz_warehouse': {('warehouse_code',)}, 'biz_customer': {('customer_code',)}, 'biz_customer_site': {('customer_id', 'site_code')}, 'biz_production_line': {('site_id', 'line_code')}, 'biz_pallet': {('pallet_code',), ('qr_token',)}, 'biz_user_warehouse': set(), 'biz_user_site': set(), 'biz_inbound_order': {('inbound_no',), ('warehouse_id', 'confirm_idempotency_key')}, 'biz_inbound_item': {('inbound_id', 'pallet_id')}, 'biz_filling_record': { ('filling_no',), ('warehouse_id', 'idempotency_key'), ('pallet_id', 'active_slot'), }, 'biz_shipment_order': { ('shipment_no',), ('source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'), }, 'biz_shipment_item': {('shipment_id', 'pallet_id')}, 'biz_usage_record': {('usage_no',), ('site_id', 'idempotency_key')}, 'biz_return_order': { ('return_no',), ('source_site_id', 'target_warehouse_id', 'request_idempotency_key'), ('source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'), }, 'biz_return_item': {('return_id', 'pallet_id')}, 'biz_pallet_inventory': set(), 'biz_pallet_event': {('pallet_id', 'event_seq'), ('pallet_id', 'idempotency_key')}, } EXPECTED_FOREIGN_KEYS = { 'biz_strain': set(), 'biz_warehouse': set(), 'biz_customer': set(), 'biz_customer_site': {('customer_id', 'biz_customer.customer_id')}, 'biz_production_line': {('site_id', 'biz_customer_site.site_id')}, 'biz_pallet': set(), 'biz_user_warehouse': {('user_id', 'sys_user.user_id'), ('warehouse_id', 'biz_warehouse.warehouse_id')}, 'biz_user_site': {('user_id', 'sys_user.user_id'), ('site_id', 'biz_customer_site.site_id')}, 'biz_inbound_order': { ('warehouse_id', 'biz_warehouse.warehouse_id'), ('confirmed_by', 'sys_user.user_id'), }, 'biz_inbound_item': { ('inbound_id', 'biz_inbound_order.inbound_id'), ('pallet_id', 'biz_pallet.pallet_id'), }, 'biz_filling_record': { ('pallet_id', 'biz_pallet.pallet_id'), ('warehouse_id', 'biz_warehouse.warehouse_id'), ('strain_id', 'biz_strain.strain_id'), ('operator_id', 'sys_user.user_id'), }, 'biz_shipment_order': { ('source_warehouse_id', 'biz_warehouse.warehouse_id'), ('target_site_id', 'biz_customer_site.site_id'), ('confirmed_by', 'sys_user.user_id'), }, 'biz_shipment_item': { ('shipment_id', 'biz_shipment_order.shipment_id'), ('pallet_id', 'biz_pallet.pallet_id'), ('active_filling_id', 'biz_filling_record.filling_id'), }, 'biz_usage_record': { ('pallet_id', 'biz_pallet.pallet_id'), ('active_filling_id', 'biz_filling_record.filling_id'), ('site_id', 'biz_customer_site.site_id'), ('line_id', 'biz_production_line.line_id'), ('operator_id', 'sys_user.user_id'), }, 'biz_return_order': { ('source_site_id', 'biz_customer_site.site_id'), ('target_warehouse_id', 'biz_warehouse.warehouse_id'), ('requested_by', 'sys_user.user_id'), ('confirmed_by', 'sys_user.user_id'), }, 'biz_return_item': { ('return_id', 'biz_return_order.return_id'), ('pallet_id', 'biz_pallet.pallet_id'), ('active_filling_id_snapshot', 'biz_filling_record.filling_id'), }, 'biz_pallet_inventory': { ('pallet_id', 'biz_pallet.pallet_id'), ('active_filling_id', 'biz_filling_record.filling_id'), ('warehouse_id', 'biz_warehouse.warehouse_id'), ('site_id', 'biz_customer_site.site_id'), ('current_line_id', 'biz_production_line.line_id'), ('last_event_id', 'biz_pallet_event.event_id'), }, 'biz_pallet_event': { ('pallet_id', 'biz_pallet.pallet_id'), ('before_warehouse_id', 'biz_warehouse.warehouse_id'), ('before_site_id', 'biz_customer_site.site_id'), ('before_line_id', 'biz_production_line.line_id'), ('after_warehouse_id', 'biz_warehouse.warehouse_id'), ('after_site_id', 'biz_customer_site.site_id'), ('after_line_id', 'biz_production_line.line_id'), ('operator_id', 'sys_user.user_id'), }, } EXPECTED_CHECKS = { 'biz_strain': {"statusin('0','1')"}, 'biz_warehouse': {"statusin('0','1')"}, 'biz_customer': {"statusin('0','1')"}, 'biz_customer_site': {"statusin('0','1')"}, 'biz_production_line': {"statusin('0','1')"}, 'biz_pallet': {'capacity_packages>0', "asset_statusin('0','1')"}, 'biz_user_warehouse': set(), 'biz_user_site': set(), 'biz_inbound_order': {"statusin('draft','confirmed')"}, 'biz_inbound_item': set(), 'biz_filling_record': {'initial_packages>0', "active_slotisnulloractive_slot='active'"}, 'biz_shipment_order': {"statusin('draft','confirmed')"}, 'biz_shipment_item': {'package_qtyisnullorpackage_qty>0'}, 'biz_usage_record': {'used_packages>0', 'remaining_packages_after>=0'}, 'biz_return_order': {"statusin('requested','confirmed')"}, 'biz_return_item': {'remaining_packages_snapshot>=0'}, 'biz_pallet_inventory': { "flow_statein('unstocked','empty_in_warehouse','filled_in_warehouse','at_site_unused','at_site_partial'," "'at_site_empty','returning')", 'remaining_packages>=0', 'version>=0', 'not(warehouse_idisnotnullandsite_idisnotnull)', }, 'biz_pallet_event': { "event_typein('inbound_confirmed','filling_confirmed','shipment_confirmed','usage_recorded'," "'return_requested','return_confirmed')" }, } LEGACY_EXPECTED_TABLES = set(EXPECTED_TABLES) LEGACY_EXPECTED_COLUMNS = {table: set(columns) for table, columns in EXPECTED_COLUMNS.items()} LEGACY_EXPECTED_PRIMARY_KEYS = dict(EXPECTED_PRIMARY_KEYS) LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQUE_KEYS.items()} LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()} LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()} EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES for table_name in TEST_BATCH_OWNED_TABLES: EXPECTED_COLUMNS[table_name].add('test_batch_id') EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id')) EXPECTED_COLUMNS.update( { 'biz_test_batch': { 'batch_id', 'batch_code', 'batch_name', 'status', 'create_by', 'create_time', 'update_by', 'update_time', 'closed_by', 'closed_at', 'purged_by', 'purged_at', 'purge_summary', 'preview_action', 'preview_token_hash', 'preview_scope_hash', 'preview_expires_at', 'remark', }, 'biz_test_batch_user': {'batch_id', 'user_id', 'create_by', 'create_time'}, } ) EXPECTED_DATETIME_COLUMNS.update( { 'biz_test_batch': {'create_time', 'update_time', 'closed_at', 'purged_at', 'preview_expires_at'}, 'biz_test_batch_user': {'create_time'}, } ) EXPECTED_PRIMARY_KEYS.update( { 'biz_test_batch': ('batch_id',), 'biz_test_batch_user': ('batch_id', 'user_id'), } ) EXPECTED_UNIQUE_KEYS.update( { 'biz_test_batch': {('batch_code',)}, 'biz_test_batch_user': set(), } ) EXPECTED_FOREIGN_KEYS.update( { 'biz_test_batch': { ('closed_by', 'sys_user.user_id'), ('purged_by', 'sys_user.user_id'), }, 'biz_test_batch_user': { ('batch_id', 'biz_test_batch.batch_id'), ('user_id', 'sys_user.user_id'), }, } ) EXPECTED_CHECKS.update( { 'biz_test_batch': { "statusin('active','closed','purged')", "preview_actionisnullorpreview_actionin('adopt','purge')", }, 'biz_test_batch_user': set(), } ) EXPECTED_PAGES = { 519: ('测试数据批次', 1, 7, 'testBatch', 'system/testBatch/index', 'system:testbatch:list'), 502: ('菌种资料', 121, 1, 'strain', 'master/strain/index', 'master:strain:list'), 503: ('客户资料', 121, 2, 'customer', 'master/customer/index', 'master:customer:list'), 504: ('客户现场', 121, 3, 'site', 'master/site/index', 'master:site:list'), 505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'), 506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'), 507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'), 508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'), 509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'), 510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'), 511: ('客户现场使用', 122, 4, 'usage', 'business/usage/index', 'business:usage:list'), 512: ('退回仓库', 122, 5, 'return', 'business/return/index', 'business:return:list'), 513: ( '托盘库存', 123, 1, 'palletInventory', 'report/palletInventory/index', 'report:palletinventory:list', ), 514: ('装填记录', 123, 2, 'reportFilling', 'report/filling/index', 'report:filling:list'), 515: ('发货出库', 123, 3, 'reportShipment', 'report/shipment/index', 'report:shipment:list'), 516: ('产线消耗', 123, 4, 'consumption', 'report/consumption/index', 'report:consumption:list'), 517: ('退回仓库', 123, 5, 'reportReturn', 'report/return/index', 'report:return:list'), 518: ('托盘追溯', 123, 6, 'palletTrace', 'report/palletTrace/index', 'report:pallettrace:list'), } BUTTON_SPECS = """ 1065 502 1 菌种查询 master:strain:query 1066 502 2 菌种新增 master:strain:add 1067 502 3 菌种修改 master:strain:edit 1069 503 1 客户查询 master:customer:query 1070 503 2 客户新增 master:customer:add 1071 503 3 客户修改 master:customer:edit 1073 504 1 现场查询 master:site:query 1074 504 2 现场新增 master:site:add 1075 504 3 现场修改 master:site:edit 1077 504 5 分配用户 master:site:assign 1078 505 1 产线查询 master:line:query 1079 505 2 产线新增 master:line:add 1080 505 3 产线修改 master:line:edit 1082 506 1 仓库查询 master:warehouse:query 1083 506 2 仓库新增 master:warehouse:add 1084 506 3 仓库修改 master:warehouse:edit 1086 506 5 分配用户 master:warehouse:assign 1087 507 1 托盘查询 master:pallet:query 1088 507 2 托盘新增 master:pallet:add 1089 507 3 托盘修改 master:pallet:edit 1091 507 5 托盘打印 master:pallet:print 1092 508 1 入库查询 business:inbound:query 1093 508 2 入库新增 business:inbound:add 1094 508 3 入库修改 business:inbound:edit 1096 508 5 入库确认 business:inbound:confirm 1097 509 1 装填查询 business:filling:query 1098 509 2 装填新增 business:filling:add 1099 510 1 发货查询 business:shipment:query 1100 510 2 发货新增 business:shipment:add 1101 510 3 发货修改 business:shipment:edit 1103 510 5 发货确认 business:shipment:confirm 1104 511 1 使用查询 business:usage:query 1105 511 2 使用新增 business:usage:add 1106 512 1 退回查询 business:return:query 1107 512 2 退回申请 business:return:request 1108 512 3 退回确认 business:return:confirm 1109 519 1 批次查询 system:testbatch:query 1110 519 2 批次新增 system:testbatch:add 1111 519 3 批次修改 system:testbatch:edit 1112 519 4 批次授权 system:testbatch:assign 1113 519 5 批次关闭 system:testbatch:close 1114 519 6 数据纳入 system:testbatch:adopt 1115 519 7 批次清理 system:testbatch:purge """ EXPECTED_BUTTONS = { int(menu_id): (name, int(parent_id), int(order_num), permission) for menu_id, parent_id, order_num, name, permission in ( line.split() for line in BUTTON_SPECS.splitlines() if line.strip() ) } EXPECTED_PAGE_PERMISSIONS = frozenset( [ 'system:testbatch:list', 'master:strain:list', 'master:customer:list', 'master:site:list', 'master:line:list', 'master:warehouse:list', 'master:pallet:list', 'business:inbound:list', 'business:filling:list', 'business:shipment:list', 'business:usage:list', 'business:return:list', 'report:palletinventory:list', 'report:filling:list', 'report:shipment:list', 'report:consumption:list', 'report:return:list', 'report:pallettrace:list', ] ) EXPECTED_BUTTON_PERMISSIONS = frozenset( [ 'master:strain:query', 'master:strain:add', 'master:strain:edit', 'master:customer:query', 'master:customer:add', 'master:customer:edit', 'master:site:query', 'master:site:add', 'master:site:edit', 'master:site:assign', 'master:line:query', 'master:line:add', 'master:line:edit', 'master:warehouse:query', 'master:warehouse:add', 'master:warehouse:edit', 'master:warehouse:assign', 'master:pallet:query', 'master:pallet:add', 'master:pallet:edit', 'master:pallet:print', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', 'business:inbound:confirm', 'business:filling:query', 'business:filling:add', 'business:shipment:query', 'business:shipment:add', 'business:shipment:edit', 'system:testbatch:query', 'system:testbatch:add', 'system:testbatch:edit', 'system:testbatch:assign', 'system:testbatch:close', 'system:testbatch:adopt', 'system:testbatch:purge', 'business:shipment:confirm', 'business:usage:query', 'business:usage:add', 'business:return:query', 'business:return:request', 'business:return:confirm', ] ) EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS BUSINESS_MENU_SPECS = """ 519|1|测试数据批次|testBatch||7|system:testbatch:list|system/testBatch/index 121|0|基础资料维护|businessMaster||1|| 122|0|业务管理|businessOperation||2|| 123|0|报表查询|businessReport||3|| 502|121|菌种资料|strain||1|master:strain:list|master/strain/index 503|121|客户资料|customer||2|master:customer:list|master/customer/index 504|121|客户现场|site||3|master:site:list|master/site/index 505|121|产线资料|line||4|master:line:list|master/line/index 506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index 507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index 508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index 509|122|工厂装填|filling||2|business:filling:list|business/filling/index 510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index 511|122|客户现场使用|usage||4|business:usage:list|business/usage/index 512|122|退回仓库|return||5|business:return:list|business/return/index 513|123|托盘库存|palletInventory||1|report:palletinventory:list|report/palletInventory/index 514|123|装填记录|reportFilling||2|report:filling:list|report/filling/index 515|123|发货出库|reportShipment||3|report:shipment:list|report/shipment/index 516|123|产线消耗|consumption||4|report:consumption:list|report/consumption/index 517|123|退回仓库|reportReturn||5|report:return:list|report/return/index 518|123|托盘追溯|palletTrace||6|report:pallettrace:list|report/palletTrace/index 1065|502|菌种查询|||1|master:strain:query| 1066|502|菌种新增|||2|master:strain:add| 1067|502|菌种修改|||3|master:strain:edit| 1069|503|客户查询|||1|master:customer:query| 1070|503|客户新增|||2|master:customer:add| 1071|503|客户修改|||3|master:customer:edit| 1073|504|现场查询|||1|master:site:query| 1074|504|现场新增|||2|master:site:add| 1075|504|现场修改|||3|master:site:edit| 1077|504|分配用户|||5|master:site:assign| 1078|505|产线查询|||1|master:line:query| 1079|505|产线新增|||2|master:line:add| 1080|505|产线修改|||3|master:line:edit| 1082|506|仓库查询|||1|master:warehouse:query| 1083|506|仓库新增|||2|master:warehouse:add| 1084|506|仓库修改|||3|master:warehouse:edit| 1086|506|分配用户|||5|master:warehouse:assign| 1087|507|托盘查询|||1|master:pallet:query| 1088|507|托盘新增|||2|master:pallet:add| 1089|507|托盘修改|||3|master:pallet:edit| 1091|507|托盘打印|||5|master:pallet:print| 1092|508|入库查询|||1|business:inbound:query| 1093|508|入库新增|||2|business:inbound:add| 1094|508|入库修改|||3|business:inbound:edit| 1096|508|入库确认|||5|business:inbound:confirm| 1097|509|装填查询|||1|business:filling:query| 1098|509|装填新增|||2|business:filling:add| 1099|510|发货查询|||1|business:shipment:query| 1100|510|发货新增|||2|business:shipment:add| 1101|510|发货修改|||3|business:shipment:edit| 1103|510|发货确认|||5|business:shipment:confirm| 1104|511|使用查询|||1|business:usage:query| 1105|511|使用新增|||2|business:usage:add| 1106|512|退回查询|||1|business:return:query| 1107|512|退回申请|||2|business:return:request| 1108|512|退回确认|||3|business:return:confirm| 1109|519|批次查询|||1|system:testbatch:query| 1110|519|批次新增|||2|system:testbatch:add| 1111|519|批次修改|||3|system:testbatch:edit| 1112|519|批次授权|||4|system:testbatch:assign| 1113|519|批次关闭|||5|system:testbatch:close| 1114|519|数据纳入|||6|system:testbatch:adopt| 1115|519|批次清理|||7|system:testbatch:purge| """.strip() def _expected_business_menu_fields() -> dict[int, tuple]: expected = {} for spec in BUSINESS_MENU_SPECS.splitlines(): menu_id, parent_id, menu_name, path, route_name, order_num, perms, component = spec.split('|') expected[int(menu_id)] = ( int(parent_id), menu_name, path, route_name, int(order_num), perms, None if component == '' else component, ) return expected EXPECTED_BUSINESS_MENU_FIELDS = _expected_business_menu_fields() LEGACY_REMOVED_BUTTON_SPECS = { 1068: ('菌种删除', 502, 4, 'master:strain:remove'), 1072: ('客户删除', 503, 4, 'master:customer:remove'), 1076: ('现场删除', 504, 4, 'master:site:remove'), 1081: ('产线删除', 505, 4, 'master:line:remove'), 1085: ('仓库删除', 506, 4, 'master:warehouse:remove'), 1090: ('托盘删除', 507, 4, 'master:pallet:remove'), 1095: ('入库删除', 508, 4, 'business:inbound:remove'), 1102: ('发货删除', 510, 4, 'business:shipment:remove'), } LEGACY_BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109)) LEGACY_EXPECTED_BUSINESS_MENU_FIELDS = { menu_id: fields for menu_id, fields in EXPECTED_BUSINESS_MENU_FIELDS.items() if menu_id in LEGACY_BUSINESS_MENU_IDS } LEGACY_EXPECTED_BUSINESS_MENU_FIELDS.update( { menu_id: (parent_id, name, '', '', order_num, permission, '') for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items() } ) LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list'} LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - { permission for permission in EXPECTED_BUTTON_PERMISSIONS if permission.startswith('system:testbatch:') } | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()} LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | { 121: (1, '基础资料维护', 'businessMaster', '', 10, '', None), 122: (1, '业务管理', 'businessOperation', '', 11, '', None), 123: (1, '报表查询', 'businessReport', '', 12, '', None), } EXPECTED_TOP_LEVEL_MENU_POSITIONS = { 121: ('基础资料维护', 0, 1), 122: ('业务管理', 0, 2), 123: ('报表查询', 0, 3), 1: ('系统管理', 0, 4), 2: ('系统监控', 0, 5), 3: ('系统工具', 0, 6), } EXPECTED_BUSINESS_ROOT_ICONS = { 121: 'documentation', 122: 'build', 123: 'chart', } EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = { 121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', 'documentation'), 122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', 'build'), 123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', 'chart'), 1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system'), 2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor'), 3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool'), } MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS) BUSINESS_MENU_IDS = ( set(range(121, 124)) | set(range(502, 519)) | {519} | set(range(1065, 1068)) | set(range(1069, 1072)) | set(range(1073, 1076)) | {1077} | set(range(1078, 1081)) | set(range(1082, 1085)) | {1086} | set(range(1087, 1090)) | {1091} | set(range(1092, 1095)) | {1096} | set(range(1097, 1099)) | set(range(1099, 1102)) | {1103} | set(range(1104, 1109)) | set(range(1109, 1116)) ) TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115} MYSQL_MENU_STABLE_COLUMNS = ( 'menu_name', 'parent_id', 'order_num', 'path', 'component', 'query', 'route_name', 'is_frame', 'is_cache', 'menu_type', 'visible', 'status', 'perms', 'icon', ) MYSQL_MENU_SQL_MODE_SAVE = 'set @pallet_workflow_original_sql_mode = @@session.sql_mode' MYSQL_MENU_SQL_MODE_ENFORCE = ( "set session sql_mode = concat_ws(',', nullif(@pallet_workflow_original_sql_mode, ''), " "if(find_in_set('strict_all_tables', @pallet_workflow_original_sql_mode) = 0, " "'strict_all_tables', null))" ) MYSQL_MENU_SQL_MODE_RESTORE = 'set session sql_mode = @pallet_workflow_original_sql_mode' def _strip_sql_comments(sql: str) -> str: sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.S) return re.sub(r'(?m)^\s*(?:--|#).*$\n?', ' ', sql) def _matching_parenthesis(text: str, opening: int) -> int: depth = 0 quote = None index = opening while index < len(text): char = text[index] if quote: if char == quote and index + 1 < len(text) and text[index + 1] == quote: index += 2 continue if char == quote: quote = None elif char in '\'"`': quote = char elif char == '(': depth += 1 elif char == ')': depth -= 1 if depth == 0: return index index += 1 raise AssertionError('unbalanced SQL parentheses') def _split_values(values: str) -> list[str]: parts = [] start = 0 depth = 0 quote = None index = 0 while index < len(values): char = values[index] if quote: if char == quote and index + 1 < len(values) and values[index + 1] == quote: index += 2 continue if char == quote: quote = None elif char in '\'"`': quote = char elif char == '(': depth += 1 elif char == ')': depth -= 1 elif char == ',' and depth == 0: parts.append(values[start:index].strip()) start = index + 1 index += 1 parts.append(values[start:].strip()) return parts def _decode_value(value: str) -> str | None: value = value.strip() if value.lower() == 'null': return None if value.startswith("'") and value.endswith("'"): return value[1:-1].replace("''", "'") return value def _sql_statements(sql: str) -> list[str]: return [statement.strip() for statement in _strip_sql_comments(sql).split(';') if statement.strip()] def _normalized_sql_statement(statement: str) -> str: return re.sub(r'\s+', ' ', statement.replace('`', '')).strip().lower() def _mysql_menu_seed_has_safe_sql_mode_order(statements: list[str]) -> bool: normalized = [_normalized_sql_statement(statement) for statement in statements] predicates = ( lambda statement: statement == MYSQL_MENU_SQL_MODE_SAVE, lambda statement: statement == MYSQL_MENU_SQL_MODE_ENFORCE, lambda statement: ( statement.startswith('insert into sys_menu values ') and ' on duplicate key update ' in statement ), lambda statement: statement == MYSQL_MENU_SQL_MODE_RESTORE, lambda statement: ( re.fullmatch( r'select\s+.+\s+from\s+sys_menu\s+where\s+menu_id\s+in\s*\([^)]+\)', statement, ) is not None ), ) positions = [] for predicate in predicates: matches = [index for index, statement in enumerate(normalized) if predicate(statement)] if len(matches) != 1: return False positions.append(matches[0]) return positions == sorted(positions) and positions[-1] == len(statements) - 1 def _insert_rows(sql: str, table: str) -> list[list[str | None]]: rows = [] pattern = re.compile( rf'\binsert(?:\s+ignore)?\s+into\s+(?:[`"]?[a-z0-9_]+[`"]?\.)?[`"]?{table}[`"]?' r'(?:\s*\([^;]*?\))?\s+values\s*', re.I | re.S, ) for statement in _sql_statements(sql): match = pattern.search(statement) if not match: continue index = match.end() while index < len(statement): while index < len(statement) and (statement[index].isspace() or statement[index] == ','): index += 1 if index >= len(statement) or statement[index] != '(': break closing = _matching_parenthesis(statement, index) rows.append([_decode_value(value) for value in _split_values(statement[index + 1 : closing])]) index = closing + 1 return rows def _create_table_bodies(sql: str) -> dict[str, str]: clean_sql = _strip_sql_comments(sql) pattern = re.compile( r'\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?' r'(?:[`"]?[a-z0-9_]+[`"]?\.)?[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\(', re.I, ) tables = {} for match in pattern.finditer(clean_sql): opening = match.end() - 1 closing = _matching_parenthesis(clean_sql, opening) tables[match.group(1).lower()] = clean_sql[opening + 1 : closing] return tables def _identifiers(value: str) -> tuple[str, ...]: return tuple(part.strip().strip('`"').lower() for part in value.split(',')) def _normalized_check(value: str) -> str: normalized = re.sub(r'[\s`"]+', '', value).lower() while normalized.startswith('(') and normalized.endswith(')'): try: if _matching_parenthesis(normalized, 0) != len(normalized) - 1: break except AssertionError: break normalized = normalized[1:-1] return normalized def _table_contract( body: str, ) -> tuple[set[str], tuple[str, ...], set[tuple[str, ...]], set[tuple[str, str]], set[str]]: columns = set() primary_key = () unique_keys = set() foreign_keys = set() checks = set() reserved = {'constraint', 'primary', 'unique', 'foreign', 'check', 'key', 'index'} for clause in _split_values(body): clean = clause.strip() identifier_match = re.match(r'[`"]?([a-z_][a-z0-9_]*)[`"]?', clean, flags=re.I) first = identifier_match.group(1).lower() if identifier_match else '' is_column = first and first not in reserved if is_column: columns.add(first) if re.search(r'\bprimary\s+key\b', clean, flags=re.I): primary_key = (first,) if re.search(r'\bunique\b', clean, flags=re.I): unique_keys.add((first,)) reference = re.search( r'\breferences\s+[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\(\s*[`"]?([a-z_][a-z0-9_]*)', clean, flags=re.I, ) if reference: foreign_keys.add((first, f'{reference.group(1).lower()}.{reference.group(2).lower()}')) primary_match = re.search(r'\bprimary\s+key\s*\(', clean, flags=re.I) if primary_match: opening = primary_match.end() - 1 primary_key = _identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)]) unique_match = re.search( r'\bunique(?:\s+(?:key|index))?(?:\s+[`"]?[a-z_][a-z0-9_]*[`"]?)?\s*\(', clean, flags=re.I, ) if unique_match: opening = unique_match.end() - 1 unique_keys.add(_identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)])) foreign_match = re.search(r'\bforeign\s+key\s*\(', clean, flags=re.I) if foreign_match: opening = foreign_match.end() - 1 local_columns = _identifiers(clean[opening + 1 : _matching_parenthesis(clean, opening)]) reference = re.search( r'\breferences\s+[`"]?([a-z_][a-z0-9_]*)[`"]?\s*\((.*?)\)', clean, flags=re.I | re.S, ) if reference: target_columns = _identifiers(reference.group(2)) for local_column, target_column in zip(local_columns, target_columns, strict=False): foreign_keys.add((local_column, f'{reference.group(1).lower()}.{target_column}')) check_match = re.search(r'\bcheck\s*\(', clean, flags=re.I) if check_match: opening = check_match.end() - 1 checks.add(_normalized_check(clean[opening + 1 : _matching_parenthesis(clean, opening)])) return columns, primary_key, unique_keys, foreign_keys, checks def _column_definitions(body: str) -> dict[str, str]: definitions = {} reserved = {'constraint', 'primary', 'unique', 'foreign', 'check', 'key', 'index'} for clause in _split_values(body): clean = clause.strip() identifier_match = re.match(r'[`"]?([a-z_][a-z0-9_]*)[`"]?', clean, flags=re.I) if identifier_match and identifier_match.group(1).lower() not in reserved: definitions[identifier_match.group(1).lower()] = clean return definitions def _assert_test_batch_sql_contract( test_case: unittest.TestCase, sql: str, *, dialect: str, is_patch: bool, ) -> None: clean_sql = _strip_sql_comments(sql).replace('`', '') normalized = re.sub(r'\s+', ' ', clean_sql).strip().lower() tables = _create_table_bodies(sql) test_case.assertNotRegex(normalized, r'\bon\s+delete\s+cascade\b') if is_patch: test_case.assertNotRegex(normalized, r'\bupdate\b') for table_name in EXPECTED_TEST_BATCH_TABLES: test_case.assertIn(table_name, tables) columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table_name]) test_case.assertEqual(EXPECTED_COLUMNS[table_name], columns, table_name) test_case.assertEqual(EXPECTED_PRIMARY_KEYS[table_name], primary_key, table_name) test_case.assertEqual(EXPECTED_UNIQUE_KEYS[table_name], unique_keys, table_name) test_case.assertEqual(EXPECTED_FOREIGN_KEYS[table_name], foreign_keys, table_name) test_case.assertEqual(EXPECTED_CHECKS[table_name], checks, table_name) create_batch = normalized.index('create table biz_test_batch (') create_batch_user = normalized.index('create table biz_test_batch_user (') owned_marker = 'alter table' if is_patch else 'create table' first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES) test_case.assertLess(create_batch, create_batch_user) test_case.assertLess(create_batch_user, first_owned) for table_name in TEST_BATCH_OWNED_TABLES: with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name): if is_patch: test_case.assertRegex( normalized, rf'\balter table {table_name}\s+add column test_batch_id bigint null\b', ) else: definitions = _column_definitions(tables[table_name]) test_case.assertIn('test_batch_id', definitions) test_case.assertRegex(definitions['test_batch_id'], r'\bbigint\b') test_case.assertNotRegex(definitions['test_batch_id'], r'\bnot\s+null\b') index_name = f'idx_{table_name}_test_batch_id' index_pattern = rf'\b(?:add\s+)?(?:index|key)\s+{index_name}\s*\(\s*test_batch_id\s*\)' test_case.assertRegex(normalized, index_pattern) test_case.assertIn("comment '测试数据批次id;null表示正式数据'", normalized) constraint_name = f'fk_{table_name}_test_batch_id' test_case.assertRegex( normalized, rf'\bconstraint\s+{constraint_name}\s+foreign\s+key\s*\(\s*test_batch_id\s*\)\s*' r'references\s+biz_test_batch\s*\(\s*batch_id\s*\)', ) def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]: rows = _insert_rows(sql, 'sys_menu') selected = [row for row in rows if row and str(row[0]).isdigit() and int(row[0]) in menu_ids] counts = {menu_id: sum(int(row[0]) == menu_id for row in selected) for menu_id in menu_ids} if set(counts) != menu_ids or any(count != 1 for count in counts.values()): raise AssertionError(f'menu row counts differ: {counts}') return {int(row[0]): row for row in selected} def _business_menu_rows(sql: str, menu_ids: set[int] | None = None) -> dict[int, list]: return _menu_rows(sql, BUSINESS_MENU_IDS if menu_ids is None else menu_ids) def _assert_top_level_menu_fields(test_case: unittest.TestCase, sql: str) -> None: rows = _menu_rows(sql, MENU_PROMOTION_IDS) actual_positions = {menu_id: (row[1], int(row[2]), int(row[3])) for menu_id, row in rows.items()} test_case.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual_positions) stable_indexes = (1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) actual_stable_fields = {menu_id: tuple(row[index] for index in stable_indexes) for menu_id, row in rows.items()} test_case.assertEqual(EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS, actual_stable_fields) def _assert_business_menu_fields( test_case: unittest.TestCase, sql: str, expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS, ) -> None: rows = _business_menu_rows(sql, set(expected_fields)) invalid_widths = {menu_id: len(row) for menu_id, row in rows.items() if len(row) != SYS_MENU_VALUE_COUNT} test_case.assertEqual({}, invalid_widths) actual = { menu_id: ( int(row[2]), row[1], row[4], row[7], int(row[3]), row[13], row[5], ) for menu_id, row in rows.items() } test_case.assertEqual(expected_fields, actual) def _assert_business_permission_inventory( test_case: unittest.TestCase, sql: str, expected_permissions: frozenset[str] = EXPECTED_PERMISSIONS, ) -> None: permission_rows = [ row for row in _insert_rows(sql, 'sys_menu') if len(row) > SYS_MENU_PERMS_INDEX and row[SYS_MENU_PERMS_INDEX] and re.match(r'^(?:master|business|report|system:testbatch):', row[SYS_MENU_PERMS_INDEX]) ] test_case.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in permission_rows)) actual_permissions = Counter(row[SYS_MENU_PERMS_INDEX] for row in permission_rows) test_case.assertEqual(Counter(expected_permissions), actual_permissions) MENU_PROMOTION_LOCK_STATEMENT = ( 'select menu_id from sys_menu where menu_id in (1, 2, 3, 121, 122, 123) order by menu_id for update' ) MENU_PROMOTION_EXPECTED_UPDATE = _normalized_sql_statement( """ update sys_menu set parent_id = case when menu_id in (121, 122, 123) then 0 else parent_id end, order_num = case menu_id when 121 then 1 when 122 then 2 when 123 then 3 when 1 then 4 when 2 then 5 when 3 then 6 else order_num end where menu_id in (1, 2, 3, 121, 122, 123) """ ) def _assert_menu_promotion_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]: normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)] guards = [ statement for statement in normalized if statement.startswith('insert into promote_business_menus_guard ') ] test_case.assertEqual(2, len(guards)) pre_guard, post_guard = guards identity_fields = ( ('menu_name', True), ('path', True), ('component', True), ('query', True), ('route_name', True), ('is_frame', False), ('is_cache', False), ('menu_type', True), ('visible', True), ('status', True), ('perms', True), ('icon', True), ) identity_comparisons = [ ( rf'\(binary\s+actual\.{field}\s*<=>\s*binary\s+expected\.{field}\)' if binary else rf'\(actual\.{field}\s*<=>\s*expected\.{field}\)' ) for field, binary in identity_fields ] identity_aggregate = r'sum\s*\(\s*' + r'\s+and\s+'.join(identity_comparisons) + r'\s*\)\s*=\s*6' for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')): conflict_token = f"'promote-business-menus-{phase}-conflict'" test_case.assertEqual(2, guard.count(conflict_token)) test_case.assertIn(f"'promote-business-menus-{phase}-verified'", guard) duplicate_key_structure = ( r'^insert into promote_business_menus_guard \(guard_token\) ' r'select guard_token from \( select ' rf'{re.escape(conflict_token)} as guard_token union all select if\(' ) test_case.assertRegex(guard, duplicate_key_structure) test_case.assertEqual(1, len(re.findall(r'\bpromote_business_menus_expected\b', guard))) expected_join = 'left join sys_menu as actual on actual.menu_id = expected.menu_id' test_case.assertEqual(1, guard.count(expected_join)) test_case.assertIn('count(actual.menu_id) = 6', guard) test_case.assertRegex(guard, identity_aggregate) old_state = ( r'sum\(\s*\(actual\.parent_id <=> expected\.old_parent_id\)\s*' r'and\s*\(actual\.order_num <=> expected\.old_order_num\)\s*\)\s*=\s*6' ) new_state = ( r'sum\(\s*\(actual\.parent_id <=> expected\.new_parent_id\)\s*' r'and\s*\(actual\.order_num <=> expected\.new_order_num\)\s*\)\s*=\s*6' ) precondition_state_group = ( rf'and\s*\(\s*{old_state}\s+or\s+{new_state}\s*\)\s*,\s*' r"'promote-business-menus-precondition-verified'\s*,\s*" r"'promote-business-menus-precondition-conflict'\s*\)" ) test_case.assertRegex(pre_guard, precondition_state_group) test_case.assertNotIn('old_parent_id', post_guard) test_case.assertNotIn('old_order_num', post_guard) postcondition_state = ( rf'and\s*{new_state}\s*,\s*' r"'promote-business-menus-postcondition-verified'\s*,\s*" r"'promote-business-menus-postcondition-conflict'\s*\)" ) test_case.assertRegex(post_guard, postcondition_state) return pre_guard, post_guard def _assert_menu_promotion_transaction_body(test_case: unittest.TestCase, sql: str) -> None: normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)] pre_guard, post_guard = _assert_menu_promotion_guard_structure(test_case, sql) def unique_index(statement: str) -> int: matches = [index for index, actual in enumerate(normalized) if actual == statement] test_case.assertEqual(1, len(matches), statement) return matches[0] start_index = unique_index('start transaction') lock_index = unique_index(MENU_PROMOTION_LOCK_STATEMENT) pre_guard_index = unique_index(pre_guard) update_index = unique_index(MENU_PROMOTION_EXPECTED_UPDATE) post_guard_index = unique_index(post_guard) commit_index = unique_index('commit') test_case.assertEqual( sorted((start_index, lock_index, pre_guard_index, update_index, post_guard_index, commit_index)), [start_index, lock_index, pre_guard_index, update_index, post_guard_index, commit_index], ) test_case.assertEqual( [MENU_PROMOTION_LOCK_STATEMENT, pre_guard, MENU_PROMOTION_EXPECTED_UPDATE, post_guard], normalized[start_index + 1 : commit_index], ) def _assert_business_menu_icon_guard_primary_key(test_case: unittest.TestCase, sql: str) -> None: normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)] guard_creates = [ statement for statement in normalized if statement.startswith('create temporary table business_menu_icon_guard ') ] test_case.assertEqual(1, len(guard_creates)) test_case.assertIn('primary key (guard_token)', guard_creates[0]) def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]: normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)] guards = [statement for statement in normalized if statement.startswith('insert into business_menu_icon_guard ')] test_case.assertEqual(2, len(guards)) pre_guard, post_guard = guards identity_fields = ( ('menu_name', True), ('path', True), ('component', True), ('query', True), ('route_name', True), ('is_frame', False), ('is_cache', False), ('menu_type', True), ('visible', True), ('status', True), ('perms', True), ('parent_id', False), ('order_num', False), ) identity_comparisons = [ ( rf'\(binary\s+actual\.{field}\s*<=>\s*binary\s+expected\.{field}\)' if binary else rf'\(actual\.{field}\s*<=>\s*expected\.{field}\)' ) for field, binary in identity_fields ] identity_aggregate = r'sum\s*\(\s*' + r'\s+and\s+'.join(identity_comparisons) + r'\s*\)\s*=\s*3' for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')): conflict_token = f"'business-menu-icon-{phase}-conflict'" test_case.assertEqual(2, guard.count(conflict_token)) test_case.assertIn(f"'business-menu-icon-{phase}-verified'", guard) duplicate_key_structure = ( r'^insert into business_menu_icon_guard \(guard_token\) ' r'select guard_token from \( select ' rf'{re.escape(conflict_token)} as guard_token union all select if\(' ) test_case.assertRegex(guard, duplicate_key_structure) test_case.assertEqual(1, len(re.findall(r'\bbusiness_menu_icon_expected\b', guard))) test_case.assertIn('count(actual.menu_id) = 3', guard) test_case.assertIn( 'left join sys_menu as actual on actual.menu_id = expected.menu_id', guard, ) test_case.assertRegex(guard, identity_aggregate) for field in ( 'menu_name', 'path', 'component', 'query', 'route_name', 'menu_type', 'visible', 'status', 'perms', ): test_case.assertIn(f'binary actual.{field} <=> binary expected.{field}', guard) for field in ('is_frame', 'is_cache', 'parent_id', 'order_num'): test_case.assertIn(f'actual.{field} <=> expected.{field}', guard) return pre_guard, post_guard class SqlContractTest(unittest.TestCase): def test_test_batch_initializers_and_patches_match_contract(self) -> None: scripts = ( (SQL_ROOT / 'shierfeng-fastapi.sql', 'mysql', False), (TEST_BATCH_MYSQL_PATCH_PATH, 'mysql', True), ) for path, dialect, is_patch in scripts: with self.subTest(path=path.name): self.assertTrue(path.is_file(), f'missing SQL: {path}') _assert_test_batch_sql_contract( self, path.read_text(encoding='utf-8'), dialect=dialect, is_patch=is_patch, ) def test_test_batch_patches_migrate_legacy_menu_permissions_without_role_grants(self) -> None: legacy_removed_ids = set(LEGACY_REMOVED_BUTTON_SPECS) for path in (TEST_BATCH_MYSQL_PATCH_PATH,): with self.subTest(path=path.name): raw = path.read_text(encoding='utf-8') statements = _sql_statements(raw) normalized = [_normalized_sql_statement(statement) for statement in statements] role_delete_indexes = [ index for index, statement in enumerate(normalized) if re.fullmatch(r'delete from sys_role_menu where menu_id in \([^)]+\)', statement) ] menu_delete_indexes = [ index for index, statement in enumerate(normalized) if re.fullmatch(r'delete from sys_menu where menu_id in \([^)]+\)', statement) ] menu_insert_indexes = [ index for index, statement in enumerate(normalized) if statement.startswith('insert into sys_menu values ') ] self.assertEqual(1, len(role_delete_indexes)) self.assertEqual(1, len(menu_delete_indexes)) self.assertEqual(1, len(menu_insert_indexes)) role_delete = normalized[role_delete_indexes[0]] menu_delete = normalized[menu_delete_indexes[0]] role_delete_ids = { int(value.strip()) for value in re.search(r'\(([^)]+)\)', role_delete).group(1).split(',') } menu_delete_ids = { int(value.strip()) for value in re.search(r'\(([^)]+)\)', menu_delete).group(1).split(',') } self.assertEqual(legacy_removed_ids, role_delete_ids) self.assertEqual(legacy_removed_ids, menu_delete_ids) self.assertLess(role_delete_indexes[0], menu_delete_indexes[0]) self.assertLess(menu_delete_indexes[0], menu_insert_indexes[0]) inserted_rows = _insert_rows(raw, 'sys_menu') inserted_by_id = {int(row[0]): row for row in inserted_rows} self.assertEqual(TEST_BATCH_MENU_IDS, set(inserted_by_id)) self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in inserted_by_id.values())) self.assertEqual('测试数据批次', inserted_by_id[519][1]) self.assertEqual('1', inserted_by_id[519][2]) self.assertEqual('7', inserted_by_id[519][3]) self.assertEqual('system:testbatch:list', inserted_by_id[519][13]) self.assertEqual(519, int(inserted_by_id[1109][2])) self.assertEqual('system:testbatch:purge', inserted_by_id[1115][13]) self.assertNotRegex('\n'.join(normalized), r'\binsert into sys_role_menu\b') def test_initializers_define_exact_business_table_set(self) -> None: for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') actual = {table for table in _create_table_bodies(sql) if table.startswith('biz_')} self.assertEqual(EXPECTED_TABLES, actual) def test_initializers_match_orm_table_contract(self) -> None: for filename in INITIALIZERS: tables = _create_table_bodies((SQL_ROOT / filename).read_text(encoding='utf-8')) for table in sorted(EXPECTED_TABLES): with self.subTest(filename=filename, table=table): self.assertIn(table, tables) columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table]) self.assertEqual(EXPECTED_COLUMNS[table], columns) self.assertEqual(EXPECTED_PRIMARY_KEYS[table], primary_key) self.assertEqual(EXPECTED_UNIQUE_KEYS[table], unique_keys) self.assertEqual(EXPECTED_FOREIGN_KEYS[table], foreign_keys) self.assertEqual(EXPECTED_CHECKS[table], checks) def test_mysql_business_datetimes_use_native_datetime_columns(self) -> None: self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values())) tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) invalid_columns = [] for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items(): definitions = _column_definitions(tables[table]) actual_columns = { column for column, definition in definitions.items() if re.search(r'\bdatetime\b', definition, flags=re.I) } self.assertEqual(expected_columns, actual_columns, table) for column in expected_columns: definition = definitions[column] if not re.search(r'\bdatetime\b', definition, flags=re.I): invalid_columns.append(f'{table}.{column}') self.assertEqual([], sorted(invalid_columns)) def test_initializers_define_fixed_business_menus_and_permissions(self) -> None: self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS)) self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS)) self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS) self.assertEqual(61, len(EXPECTED_PERMISSIONS)) self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS)) for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') _assert_business_menu_fields(self, sql) _assert_business_permission_inventory(self, sql) rows = _business_menu_rows(sql) self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in rows.values())) self.assertEqual( ('基础资料维护', '0', '1', 'businessMaster', 'M', ''), tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)), ) self.assertEqual( ('业务管理', '0', '2', 'businessOperation', 'M', ''), tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)), ) self.assertEqual( ('报表查询', '0', '3', 'businessReport', 'M', ''), tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)), ) for menu_id in (121, 122, 123): self.assertEqual( (None, '', '', '1', '0', '0', '0'), tuple(rows[menu_id][i] for i in (5, 6, 7, 8, 9, 11, 12)) ) for menu_id, expected in EXPECTED_PAGES.items(): row = rows[menu_id] actual = (row[1], int(row[2]), int(row[3]), row[4], row[5], row[13]) self.assertEqual(expected, actual, menu_id) self.assertEqual( ('', '', '1', '0', 'C', '0', '0'), tuple(row[index] for index in (6, 7, 8, 9, 10, 11, 12)) ) for menu_id, expected in EXPECTED_BUTTONS.items(): row = rows[menu_id] actual = (row[1], int(row[2]), int(row[3]), row[13]) self.assertEqual(expected, actual, menu_id) self.assertEqual( ('', '', '', '', '1', '0', 'F', '0', '0'), tuple(row[index] for index in (4, 5, 6, 7, 8, 9, 10, 11, 12)), ) def test_initializers_define_expected_top_level_menu_order(self) -> None: for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') _assert_top_level_menu_fields(self, sql) def test_initializers_define_renderable_business_root_icons(self) -> None: icon_root = SQL_ROOT.parent.parent / 'shierfeng-fastapi-frontend' / 'src' / 'assets' / 'icons' / 'svg' for icon in EXPECTED_BUSINESS_ROOT_ICONS.values(): with self.subTest(icon=icon): self.assertTrue((icon_root / f'{icon}.svg').is_file()) for filename in INITIALIZERS: with self.subTest(filename=filename): rows = _menu_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), set(EXPECTED_BUSINESS_ROOT_ICONS)) actual = {menu_id: row[SYS_MENU_ICON_INDEX] for menu_id, row in rows.items()} self.assertEqual(EXPECTED_BUSINESS_ROOT_ICONS, actual) def test_business_menu_icon_patch_is_fail_closed_atomic_and_exact(self) -> None: self.assertTrue(BUSINESS_MENU_ICON_PATCH_PATH.is_file(), f'missing patch: {BUSINESS_MENU_ICON_PATCH_PATH}') raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8') executable = _strip_sql_comments(raw).replace('`', '') normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)] _assert_business_menu_icon_guard_primary_key(self, raw) pre_guard, post_guard = _assert_business_menu_icon_guard_structure(self, raw) for required_note in ( '未自动执行', '执行前备份', '可幂等重跑', '禁止使用 mysql --force', '独立连接', '20260727_promote_business_menus_mysql.sql', '禁止逆序重跑旧补丁', ): self.assertIn(required_note, raw) self.assertNotIn('--force', executable.lower()) self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b') expected_rows = [ ( '121', '基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '0', '1', '#', 'documentation', ), ('122', '业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '0', '2', '#', 'build'), ('123', '报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '0', '3', '#', 'chart'), ] self.assertEqual( expected_rows, [tuple(row) for row in _insert_rows(raw, 'business_menu_icon_expected')], ) start_index = normalized.index('start transaction') commit_index = normalized.index('commit') self.assertEqual(1, normalized.count('start transaction')) self.assertEqual(1, normalized.count('commit')) transaction_body = normalized[start_index + 1 : commit_index] lock_statement = 'select menu_id from sys_menu where menu_id in (121, 122, 123) order by menu_id for update' expected_update = ( "update sys_menu set icon = case menu_id when 121 then 'documentation' when 122 then 'build' " "when 123 then 'chart' else icon end where menu_id in (121, 122, 123)" ) self.assertEqual(lock_statement, transaction_body[0]) self.assertEqual(expected_update, transaction_body[2]) self.assertEqual(4, len(transaction_body)) self.assertRegex( pre_guard, r'sum\(\(binary actual\.icon <=> binary expected\.old_icon\)\) = 3 or ' r'sum\(\(binary actual\.icon <=> binary expected\.new_icon\)\) = 3', ) self.assertNotIn('old_icon', post_guard) self.assertIn('sum((binary actual.icon <=> binary expected.new_icon)) = 3', post_guard) permanent_dml = [ statement for statement in normalized if re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement) and not statement.startswith('insert into business_menu_icon_expected ') and not statement.startswith('insert into business_menu_icon_guard ') ] self.assertEqual([expected_update], permanent_dml) self.assertEqual( 'select menu_id, menu_name, parent_id, order_num, path, icon from sys_menu ' 'where menu_id in (121, 122, 123) order by order_num, menu_id', normalized[-1], ) def test_business_menu_icon_patch_rejects_guard_primary_key_removal(self) -> None: raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8') mutated, replacements = re.subn( r',\s*primary\s+key\s*\(guard_token\)', '', raw, count=1, flags=re.I, ) self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_business_menu_icon_guard_primary_key(self, mutated) def test_business_menu_icon_guard_contract_rejects_structural_mutations(self) -> None: raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8') mutation_specs = ( ('union_distinct', r'\bunion\s+all\b', 'union distinct'), ( 'identity_threshold', r'(\(actual\.order_num\s*<=>\s*expected\.order_num\)\s*\)\s*)=\s*3', r'\1>= 0', ), ) for name, pattern, replacement in mutation_specs: with self.subTest(mutation=name): mutated, replacements = re.subn(pattern, replacement, raw, count=1, flags=re.I) self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_business_menu_icon_guard_structure(self, mutated) def test_top_level_menu_contract_rejects_same_stable_field_drift_in_both_initializers(self) -> None: mutations = ( (1, '系统管理', 'system', 'driftSystemIcon'), (121, '基础资料维护', 'documentation', 'driftBusinessIcon'), ) for filename in INITIALIZERS: sql = (SQL_ROOT / filename).read_text(encoding='utf-8') for menu_id, menu_name, original_icon, drifted_icon in mutations: with self.subTest(filename=filename, menu_id=menu_id): mutated, replacements = re.subn( rf"(\(?\s*'?{menu_id}'?\s*,\s*'{menu_name}'.*?,\s*)'{re.escape(original_icon)}'" r"(\s*,\s*'admin')", rf"\1'{drifted_icon}'\2", sql, count=1, ) self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_top_level_menu_fields(self, mutated) def test_business_permission_inventory_rejects_duplicate_permission(self) -> None: sql = (SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8') sql += """ insert into sys_menu values( '1999', '重复权限', '1', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, '' ); """ with self.assertRaises(AssertionError): _assert_business_permission_inventory(self, sql) def test_business_menu_contract_rejects_same_field_drift_in_both_initializers(self) -> None: for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') mutated, replacements = re.subn( r"(\(?\s*'?502'?\s*,\s*)'菌种资料'", r"\1'漂移菌种资料'", sql, count=1, ) self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_business_menu_fields(self, mutated) def test_initializers_do_not_grant_new_business_menus_to_roles(self) -> None: for filename in INITIALIZERS: with self.subTest(filename=filename): rows = _insert_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), 'sys_role_menu') granted = {int(row[1]) for row in rows if len(row) > 1 and str(row[1]).isdigit()} self.assertFalse(BUSINESS_MENU_IDS & granted) self.assertFalse({4, 99, 118, 119, 1061, 1062, 1063, 1064} & granted) def test_initializers_exclude_removed_ai_and_external_link_seeds(self) -> None: removed_menu_ids = {4, 99, 118, 119, 1061, 1062, 1063, 1064} for filename in INITIALIZERS: with self.subTest(filename=filename): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') menu_ids = { int(row[0]) for row in _insert_rows(sql, 'sys_menu') if row and row[0] is not None and str(row[0]).isdigit() } self.assertFalse(removed_menu_ids & menu_ids) dict_types = _insert_rows(sql, 'sys_dict_type') self.assertFalse(any(row[0] == '12' or row[2] == 'ai_provider_type' for row in dict_types)) dict_data = _insert_rows(sql, 'sys_dict_data') self.assertFalse( any(int(row[0]) in REMOVED_AI_DICT_CODES or row[4] == 'ai_provider_type' for row in dict_data) ) def test_initializer_uses_mysql_native_menu_allocator(self) -> None: mysql = (SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8').lower() self.assertRegex(mysql, r'auto_increment\s*=\s*2000') self.assertNotRegex(mysql, r'\bbigserial\b|\balter\s+sequence\b|\bcomment\s+on\b') def test_initializers_create_event_before_inventory_foreign_key(self) -> None: for filename in INITIALIZERS: with self.subTest(filename=filename): sql = _strip_sql_comments((SQL_ROOT / filename).read_text(encoding='utf-8')).lower() self.assertIn('create table biz_pallet_event', sql) self.assertIn('create table biz_pallet_inventory', sql) self.assertLess( sql.index('create table biz_pallet_event'), sql.index('create table biz_pallet_inventory') ) def test_initializers_drop_business_tables_before_recreating_referenced_system_tables(self) -> None: reverse_dependency_order = ( 'biz_pallet_inventory', 'biz_pallet_event', 'biz_return_item', 'biz_return_order', 'biz_usage_record', 'biz_shipment_item', 'biz_shipment_order', 'biz_filling_record', 'biz_inbound_item', 'biz_inbound_order', 'biz_user_site', 'biz_user_warehouse', 'biz_pallet', 'biz_production_line', 'biz_customer_site', 'biz_customer', 'biz_warehouse', 'biz_strain', 'biz_test_batch_user', 'biz_test_batch', ) for filename in INITIALIZERS: with self.subTest(filename=filename): sql = _strip_sql_comments((SQL_ROOT / filename).read_text(encoding='utf-8')).lower() drop_positions = [sql.index(f'drop table if exists {table};') for table in reverse_dependency_order] self.assertEqual(sorted(drop_positions), drop_positions) self.assertLess(drop_positions[-1], sql.index('drop table if exists sys_user')) def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None: # noqa: PLR0915 self.assertTrue(MENU_PROMOTION_PATCH_PATH.is_file(), f'missing patch: {MENU_PROMOTION_PATCH_PATH}') raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8') executable = _strip_sql_comments(raw).replace('`', '') normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)] for required_note in ('未自动执行', '执行前备份', '可幂等重跑', '禁止使用 mysql --force', '独立连接'): self.assertIn(required_note, raw) self.assertNotIn('--force', executable.lower()) self.assertNotIn('continue-on-error', raw.lower()) expected_rows = [ ('1', '系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'), ('2', '系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'), ('3', '系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'), ( '121', '基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '10', '0', '1', ), ( '122', '业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '11', '0', '2', ), ( '123', '报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '12', '0', '3', ), ] self.assertEqual(expected_rows, [tuple(row) for row in _insert_rows(raw, 'promote_business_menus_expected')]) def unique_index(predicate: Callable[[str], bool], label: str) -> int: matches = [index for index, statement in enumerate(normalized) if predicate(statement)] self.assertEqual(1, len(matches), label) return matches[0] expected_columns = ( 'menu_id', 'menu_name', 'path', 'component', 'query', 'route_name', 'is_frame', 'is_cache', 'menu_type', 'visible', 'status', 'perms', 'icon', 'old_parent_id', 'old_order_num', 'new_parent_id', 'new_order_num', ) expected_create_index = unique_index( lambda statement: statement.startswith('create temporary table promote_business_menus_expected '), 'expected table creation', ) expected_insert_index = unique_index( lambda statement: statement.startswith('insert into promote_business_menus_expected '), 'expected row insertion', ) expected_create = normalized[expected_create_index] for column in expected_columns: self.assertRegex(expected_create, rf'\b{column}\b') guard_create_index = unique_index( lambda statement: statement.startswith('create temporary table promote_business_menus_guard '), 'guard table creation', ) self.assertIn('primary key (guard_token)', normalized[guard_create_index]) start_indexes = [index for index, statement in enumerate(normalized) if statement == 'start transaction'] commit_indexes = [index for index, statement in enumerate(normalized) if statement == 'commit'] self.assertEqual(1, len(start_indexes)) self.assertEqual(1, len(commit_indexes)) start_index = start_indexes[0] commit_index = commit_indexes[0] initial_cleanup_indexes = ( unique_index( lambda statement: statement == 'drop temporary table if exists promote_business_menus_expected', 'expected table initial cleanup', ), unique_index( lambda statement: statement == 'drop temporary table if exists promote_business_menus_guard', 'guard table initial cleanup', ), ) self.assertTrue( all( index < start_index for index in ( *initial_cleanup_indexes, expected_create_index, expected_insert_index, guard_create_index, ) ) ) lock_index = unique_index( lambda statement: statement == MENU_PROMOTION_LOCK_STATEMENT, 'six-row locking read', ) pre_guard, post_guard = _assert_menu_promotion_guard_structure(self, raw) guard_indexes = [ index for index, statement in enumerate(normalized) if statement.startswith('insert into promote_business_menus_guard ') ] self.assertEqual(2, len(guard_indexes)) pre_guard_index, post_guard_index = guard_indexes self.assertEqual((pre_guard, post_guard), tuple(normalized[index] for index in guard_indexes)) expected_update = MENU_PROMOTION_EXPECTED_UPDATE _assert_menu_promotion_transaction_body(self, raw) update_index = unique_index( lambda statement: statement == expected_update, 'exact permanent update', ) permanent_dml = [] for statement in normalized: if not re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement): continue if statement.startswith('insert into promote_business_menus_expected '): continue if statement.startswith('insert into promote_business_menus_guard '): continue permanent_dml.append(statement) self.assertEqual([expected_update], permanent_dml) self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b') self.assertLess(start_index, lock_index) self.assertLess(lock_index, pre_guard_index) self.assertLess(pre_guard_index, update_index) self.assertLess(update_index, post_guard_index) self.assertLess(post_guard_index, commit_index) guard_drop_index = unique_index( lambda statement: statement == 'drop temporary table promote_business_menus_guard', 'guard table final cleanup', ) expected_drop_index = unique_index( lambda statement: statement == 'drop temporary table promote_business_menus_expected', 'expected table final cleanup', ) self.assertLess(commit_index, guard_drop_index) self.assertLess(commit_index, expected_drop_index) expected_verification = ( 'select menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms ' 'from sys_menu where menu_id in (1, 2, 3, 121, 122, 123) order by order_num, menu_id' ) self.assertEqual(expected_verification, normalized[-1]) self.assertLess(guard_drop_index, len(normalized) - 1) self.assertLess(expected_drop_index, len(normalized) - 1) def test_menu_promotion_guard_contract_rejects_structural_mutations(self) -> None: raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8') mutation_specs = ( ('union_distinct', r'\bunion\s+all\b', 'union distinct'), ( 'old_and_new_state', r'(old_order_num\)\s*\)\s*=\s*6\s*)or(\s+sum\s*\()', r'\1and\2', ), ('inner_join_actual', r'\bleft\s+join\s+sys_menu\s+as\s+actual\b', 'inner join sys_menu as actual'), ( 'duplicate_expected_reference', r'\bfrom\s+promote_business_menus_expected\s+as\s+expected\b', 'from promote_business_menus_expected as expected ' 'cross join promote_business_menus_expected as duplicate_expected', ), ) for name, pattern, replacement in mutation_specs: with self.subTest(mutation=name): mutated, replacements = re.subn(pattern, replacement, raw, count=1, flags=re.I) self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_menu_promotion_guard_structure(self, mutated) def test_menu_promotion_contract_rejects_identity_threshold_and_transaction_body_mutations(self) -> None: raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8') identity_threshold_mutation, replacements = re.subn( r'(\(binary\s+actual\.icon\s+<=>\s+binary\s+expected\.icon\)\s*\)\s*)=\s*6', r'\1>= 0', raw, count=1, flags=re.I, ) with self.subTest(mutation='identity_threshold'): self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_menu_promotion_guard_structure(self, identity_threshold_mutation) transaction_ddl_mutation, replacements = re.subn( r'(start\s+transaction\s*;)', r'\1\nalter table sys_menu engine=innodb;', raw, count=1, flags=re.I, ) with self.subTest(mutation='transaction_ddl'): self.assertEqual(1, replacements) with self.assertRaises(AssertionError): _assert_menu_promotion_transaction_body(self, transaction_ddl_mutation) def test_patch_exists_and_preserves_ai_business_tables(self) -> None: self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') raw = PATCH_PATH.read_text(encoding='utf-8').lower() sql = re.sub(r'/\*.*?\*/', '', raw, flags=re.S) sql = re.sub(r'(?m)^\s*(?:--|#).*$\n?', '', sql).replace('`', '') for table in ( 'ai_models', 'ai_chat_config', 'ai_sessions', 'ai_memories', 'ai_metrics', 'ai_eval_runs', 'ai_knowledge', 'ai_culture', 'ai_traces', 'ai_spans', 'ai_schema_versions', ): qualified = rf'(?:[a-z0-9_]+\.)?{table}' patterns = ( rf'\bdrop\s+(?:temporary\s+)?table\s+(?:if\s+exists\s+)?{qualified}\b', rf'\btruncate\s+(?:table\s+)?{qualified}\b', rf'\bdelete\s+from\s+{qualified}\b', rf'\balter\s+table\s+(?:if\s+exists\s+)?{qualified}\b', ) for pattern in patterns: self.assertIsNone(re.search(pattern, sql), f'dangerous AI table mutation: {table}') self.assertNotRegex(sql, r'\b(?:drop\s+(?:temporary\s+)?table|truncate\s+(?:table\s+)?|delete\s+from)\s+biz_') def test_patch_is_documented_idempotent_and_contains_full_contract(self) -> None: self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') raw = PATCH_PATH.read_text(encoding='utf-8') lower = raw.lower() self.assertIn('隐式提交', raw) self.assertIn('备份', raw) self.assertIn('人工审查', raw) self.assertIn('未自动执行', raw) self.assertLess(lower.index('ddl'), lower.index('dml')) self.assertEqual(18, len(re.findall(r'create\s+table\s+if\s+not\s+exists\s+`?biz_', lower))) tables = _create_table_bodies(raw) self.assertEqual(LEGACY_EXPECTED_TABLES, {table for table in tables if table.startswith('biz_')}) for table in LEGACY_EXPECTED_TABLES: with self.subTest(table=table): columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table]) self.assertEqual(LEGACY_EXPECTED_COLUMNS[table], columns) self.assertEqual(LEGACY_EXPECTED_PRIMARY_KEYS[table], primary_key) self.assertEqual(LEGACY_EXPECTED_UNIQUE_KEYS[table], unique_keys) self.assertEqual(LEGACY_EXPECTED_FOREIGN_KEYS[table], foreign_keys) self.assertEqual(LEGACY_EXPECTED_CHECKS[table], checks) _assert_business_menu_fields(self, raw, LEGACY_PATCH_BUSINESS_MENU_FIELDS) _assert_business_permission_inventory(self, raw, LEGACY_EXPECTED_PERMISSIONS) patch_rows = _business_menu_rows(raw, LEGACY_BUSINESS_MENU_IDS) mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) compared_indexes = (0, 1, *range(4, 15)) common_menu_ids = LEGACY_BUSINESS_MENU_IDS & BUSINESS_MENU_IDS expected_legacy_rows = { menu_id: tuple( '#' if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS else row[index] for index in compared_indexes ) for menu_id, row in mysql_rows.items() if menu_id in common_menu_ids } self.assertEqual( expected_legacy_rows, { menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in patch_rows.items() if menu_id in common_menu_ids }, ) self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b') self.assertNotRegex(lower, r'\binsert\s+ignore\b') self.assertNotRegex(_strip_sql_comments(lower), r'\binsert(?:\s+ignore)?\s+into\s+`?sys_role_menu`?\b') self.assertIn('/getRouters', raw) self.assertIn('用户信息', raw) self.assertIn('sys_dict:ai_provider_type', raw) executable = _strip_sql_comments(raw) self.assertNotIn('/getRouters', executable) self.assertNotIn('sys_dict:ai_provider_type', executable) self.assertNotRegex(lower, r'\bbigserial\b|\balter\s+sequence\b|\bcomment\s+on\b') def test_patch_menu_seed_fails_closed_on_fixed_id_conflicts(self) -> None: raw = PATCH_PATH.read_text(encoding='utf-8') executable = _strip_sql_comments(raw).replace('`', '').lower() self.assertNotRegex(executable, r'\binsert\s+ignore\b') statements = _sql_statements(raw) self.assertTrue(_mysql_menu_seed_has_safe_sql_mode_order(statements)) menu_insert_statements = [ statement.replace('`', '').lower() for statement in statements if re.match(r'\s*insert(?:\s+ignore)?\s+into\s+`?sys_menu`?\b', statement, flags=re.I) ] self.assertEqual(1, len(menu_insert_statements)) menu_insert_statement = menu_insert_statements[0] menu_insert = re.fullmatch( r'\s*insert\s+into\s+sys_menu\s+values\s*.*?' r'\bon\s+duplicate\s+key\s+update\s+' r'menu_name\s*=\s*if\s*\(\s*(?P.*?)\s*,\s*menu_name\s*,\s*null\s*\)\s*', menu_insert_statement, flags=re.S, ) self.assertIsNotNone(menu_insert, 'sys_menu seed must use a fail-closed upsert') self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(_insert_rows(menu_insert_statement, 'sys_menu'))) expected_guard = ' and '.join(f'{column} <=> values({column})' for column in MYSQL_MENU_STABLE_COLUMNS) actual_guard = re.sub(r'\s+', ' ', menu_insert.group('guard')).strip() self.assertEqual(expected_guard, actual_guard) self.assertIn('任一稳定字段冲突', raw) self.assertIn('menu_name NOT NULL', raw) self.assertIn('中止整条语句', raw) self.assertIn('人工解决冲突', raw) self.assertIn('不会静默覆盖未知 ID', raw) verification_statement = statements[-1].replace('`', '').lower() verification = re.fullmatch( r'\s*select\s+(?P.*?)\s+from\s+sys_menu\s+' r'where\s+menu_id\s+in\s*\((?P[^)]+)\)\s*', verification_statement, flags=re.S, ) self.assertIsNotNone(verification, 'patch must end with a manual business menu verification query') verification_columns = tuple(column.strip() for column in verification.group('columns').split(',')) self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), verification_columns) verification_ids = [int(value.strip()) for value in verification.group('ids').split(',')] self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(verification_ids)) self.assertEqual(LEGACY_BUSINESS_MENU_IDS, set(verification_ids)) self.assertRegex(raw, r'查询结果必须为\s*64') normalized = [_normalized_sql_statement(statement) for statement in statements] marker_indexes = { 'save': normalized.index(MYSQL_MENU_SQL_MODE_SAVE), 'enforce': normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE), 'insert': next( index for index, statement in enumerate(normalized) if statement.startswith('insert into sys_menu values ') ), 'restore': normalized.index(MYSQL_MENU_SQL_MODE_RESTORE), 'verification': len(statements) - 1, } mutations = { f'missing_{name}': [ statement for index, statement in enumerate(statements) if index != marker_indexes[name] ] for name in ('save', 'enforce', 'restore') } strict_after_insert = statements.copy() strict_after_insert[marker_indexes['enforce']], strict_after_insert[marker_indexes['insert']] = ( strict_after_insert[marker_indexes['insert']], strict_after_insert[marker_indexes['enforce']], ) mutations['strict_after_insert'] = strict_after_insert restore_after_verification = statements.copy() ( restore_after_verification[marker_indexes['restore']], restore_after_verification[marker_indexes['verification']], ) = ( restore_after_verification[marker_indexes['verification']], restore_after_verification[marker_indexes['restore']], ) mutations['restore_after_verification'] = restore_after_verification for name, mutation in mutations.items(): with self.subTest(mutation=name): self.assertFalse(_mysql_menu_seed_has_safe_sql_mode_order(mutation)) def test_patch_preflights_new_menu_ids_before_atomic_permanent_dml(self) -> None: raw = PATCH_PATH.read_text(encoding='utf-8') statements = _sql_statements(raw) normalized = [_normalized_sql_statement(statement) for statement in statements] expected_table = 'pallet_workflow_menu_seed_expected' conflict_guard = 'pallet_workflow_menu_seed_guard' def statement_indexes(pattern: str) -> list[int]: return [index for index, statement in enumerate(normalized) if re.fullmatch(pattern, statement, flags=re.S)] expected_initial_drops = statement_indexes(rf'drop temporary table if exists {expected_table}') expected_creates = statement_indexes(rf'create temporary table {expected_table}\s*\(.*\)') expected_inserts = statement_indexes(rf'insert into {expected_table}\s*\(.*') guard_initial_drops = statement_indexes(rf'drop temporary table if exists {conflict_guard}') guard_creates = statement_indexes(rf'create temporary table {conflict_guard}\s*\(.*\)') guard_inserts = statement_indexes(rf'insert into {conflict_guard}\s*\(.*') guard_final_drops = statement_indexes(rf'drop temporary table {conflict_guard}') expected_final_drops = statement_indexes(rf'drop temporary table {expected_table}') markers = { 'expected table initial cleanup': expected_initial_drops, 'expected table creation': expected_creates, 'expected seed insertion': expected_inserts, 'conflict guard initial cleanup': guard_initial_drops, 'conflict guard creation': guard_creates, 'conflict guard check': guard_inserts, 'conflict guard success cleanup': guard_final_drops, 'expected table success cleanup': expected_final_drops, } for name, indexes in markers.items(): with self.subTest(marker=name): self.assertEqual(1, len(indexes), f'missing or duplicate {name}') legacy_guard_cleanup = normalized.index('drop temporary table pallet_workflow_legacy_seed_guard') permanent_deletes = [ index for index, statement in enumerate(normalized) if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement) ] self.assertTrue(permanent_deletes) first_permanent_delete = permanent_deletes[0] ordered_markers = ( legacy_guard_cleanup, expected_initial_drops[0], expected_creates[0], expected_inserts[0], guard_initial_drops[0], guard_creates[0], guard_inserts[0], guard_final_drops[0], expected_final_drops[0], first_permanent_delete, ) self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers) expected_insert = statements[expected_inserts[0]].replace('`', '').lower() expected_insert_match = re.fullmatch( rf'\s*insert\s+into\s+{expected_table}\s*\((?P[^)]+)\)\s+values\s+.*', expected_insert, flags=re.S, ) self.assertIsNotNone(expected_insert_match) expected_columns = tuple(column.strip() for column in expected_insert_match.group('columns').split(',')) self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), expected_columns) expected_rows = _insert_rows(statements[expected_inserts[0]], expected_table) self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(expected_rows)) expected_rows_by_id = {int(row[0]): tuple(row) for row in expected_rows} self.assertEqual(LEGACY_BUSINESS_MENU_IDS, set(expected_rows_by_id)) seed_rows_by_id = { menu_id: tuple(row[: len(expected_columns)]) for menu_id, row in _business_menu_rows(raw, LEGACY_BUSINESS_MENU_IDS).items() } self.assertEqual(seed_rows_by_id, expected_rows_by_id) guard_create = normalized[guard_creates[0]] self.assertRegex(guard_create, r'primary key\s*\(\s*guard_token\s*\)') guard_insert = normalized[guard_inserts[0]] self.assertEqual(2, guard_insert.count("select 'business-menu-seed-conflict'")) self.assertIn( "union all select 'business-menu-seed-conflict' as guard_token where exists (", guard_insert, ) self.assertNotIn(' on duplicate key update ', guard_insert) expected_comparisons = ' and '.join( f'actual.{column} <=> expected.{column}' for column in MYSQL_MENU_STABLE_COLUMNS ) guard_insert = re.sub(r'\(\s+', '(', guard_insert) guard_insert = re.sub(r'\s+\)', ')', guard_insert) self.assertIn( f'from sys_menu as actual join {expected_table} as expected ' f'on actual.menu_id = expected.menu_id where not ({expected_comparisons})', guard_insert, ) preflight_sql = '; '.join(normalized[expected_creates[0] : first_permanent_delete]) self.assertNotRegex( preflight_sql, r'\b(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_menu\b', ) def test_patch_wraps_permanent_seed_dml_in_explicit_transaction(self) -> None: statements = _sql_statements(PATCH_PATH.read_text(encoding='utf-8')) normalized = [_normalized_sql_statement(statement) for statement in statements] start_transactions = [index for index, statement in enumerate(normalized) if statement == 'start transaction'] commits = [index for index, statement in enumerate(normalized) if statement == 'commit'] self.assertEqual(1, len(start_transactions), 'permanent seed DML must start one explicit transaction') self.assertEqual(1, len(commits), 'permanent seed DML must commit one explicit transaction') start_transaction = start_transactions[0] commit = commits[0] permanent_deletes = [ index for index, statement in enumerate(normalized) if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement) ] self.assertTrue(permanent_deletes) menu_seed_inserts = [ index for index, statement in enumerate(normalized) if statement.startswith('insert into sys_menu values ') ] self.assertEqual(1, len(menu_seed_inserts)) menu_seed_insert = menu_seed_inserts[0] ordered_markers = ( normalized.index(MYSQL_MENU_SQL_MODE_SAVE), normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE), start_transaction, permanent_deletes[0], permanent_deletes[-1], menu_seed_insert, commit, normalized.index(MYSQL_MENU_SQL_MODE_RESTORE), ) self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers) permanent_mutations = [*permanent_deletes, menu_seed_insert] self.assertTrue(all(start_transaction < index < commit for index in permanent_mutations)) self.assertTrue( all( index < start_transaction for index, statement in enumerate(normalized) if re.match(r'(?:create|drop) temporary table\b', statement) ) ) transaction_sql = '; '.join(normalized[start_transaction + 1 : commit]) self.assertNotRegex(transaction_sql, r'\b(?:create|drop|alter|truncate)\b') def test_patch_cleanup_is_explicit_and_ordered(self) -> None: self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower() role_cleanup = re.search(r'delete\s+from\s+sys_role_menu\s+where\s+menu_id\s+in\s*\([^;]+\)', sql) ai_menu_cleanup = re.search(r'delete\s+from\s+sys_menu\s+where\s+menu_id\s+in\s*\([^;]+\)', sql) dict_data_cleanup = re.search(r'delete\s+from\s+sys_dict_data\s+where\s+[^;]+', sql) dict_type_cleanup = re.search(r'delete\s+from\s+sys_dict_type\s+where\s+[^;]+', sql) external_link_cleanup = re.search(r'delete\s+from\s+sys_menu\s+where\s+menu_id\s*=\s*99\b', sql) for cleanup in ( role_cleanup, ai_menu_cleanup, dict_data_cleanup, dict_type_cleanup, external_link_cleanup, ): self.assertIsNotNone(cleanup) self.assertRegex(role_cleanup.group(), r'\b4\b.*\b99\b.*\b118\b.*\b119\b.*\b1061\b.*\b1064\b') self.assertRegex(ai_menu_cleanup.group(), r'\b4\b.*\b118\b.*\b119\b.*\b1061\b.*\b1064\b') self.assertRegex(dict_data_cleanup.group(), r'dict_code\s+between\s+33\s+and\s+69') self.assertIn('ai_provider_type', dict_data_cleanup.group()) self.assertRegex(dict_type_cleanup.group(), r'dict_id\s*=\s*12') self.assertIn('ai_provider_type', dict_type_cleanup.group()) self.assertLess(role_cleanup.start(), ai_menu_cleanup.start()) self.assertLess(ai_menu_cleanup.start(), dict_data_cleanup.start()) self.assertLess(dict_data_cleanup.start(), dict_type_cleanup.start()) self.assertLess(dict_type_cleanup.start(), external_link_cleanup.start()) self.assertNotRegex(sql, r'\bdelete\s+from\s+(?:[a-z0-9_]+\.)?(?:biz|ai)_[a-z0-9_]+\b') def test_patch_cleanup_fails_closed_before_deleting_legacy_seed_ids(self) -> None: sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower() guard_create = re.search( r'create\s+temporary\s+table\s+pallet_workflow_legacy_seed_guard\b', sql, ) guard_insert = re.search( r'insert\s+into\s+pallet_workflow_legacy_seed_guard\b[\s\S]+?;', sql, ) guard_drops = list( re.finditer( r'drop\s+temporary\s+table(?:\s+if\s+exists)?\s+pallet_workflow_legacy_seed_guard\b', sql, ) ) guard_drop = guard_drops[-1] if guard_drops else None role_cleanup = re.search(r'delete\s+from\s+sys_role_menu\b', sql) for statement in (guard_create, guard_insert, guard_drop, role_cleanup): self.assertIsNotNone(statement) self.assertLess(guard_create.start(), guard_insert.start()) self.assertLess(guard_insert.start(), guard_drop.start()) self.assertLess(guard_drop.start(), role_cleanup.start()) guard_sql = guard_insert.group() expected_menu_identities = ( (4, 'ai 管理', 0, 'ai', None, '', 'm'), (99, '施尔丰仓库', 0, 'https://git.yujiangrubber.cn/niuniu/shierfeng', None, '', 'm'), (118, '模型管理', 4, 'model', 'ai/model/index', 'ai:model:list', 'c'), (119, 'ai 对话', 4, 'chat', 'ai/chat/index', 'ai:chat:list', 'c'), (1061, '模型查询', 118, '#', '', 'ai:model:query', 'f'), (1062, '模型新增', 118, '#', '', 'ai:model:add', 'f'), (1063, '模型修改', 118, '#', '', 'ai:model:edit', 'f'), (1064, '模型删除', 118, '#', '', 'ai:model:remove', 'f'), ) for menu_id, name, parent_id, path, component, permission, menu_type in expected_menu_identities: with self.subTest(menu_id=menu_id): component_pattern = ( r'component\s*<=>\s*null' if component is None else rf"component\s*<=>\s*'{re.escape(component)}'" ) identity_pattern = ( rf'menu_id\s*=\s*{menu_id}\b[\s\S]+?' rf"menu_name\s*<=>\s*'{re.escape(name)}'[\s\S]+?" rf'parent_id\s*<=>\s*{parent_id}\b[\s\S]+?' rf"path\s*<=>\s*'{re.escape(path)}'[\s\S]+?" rf'{component_pattern}[\s\S]+?' rf"perms\s*<=>\s*'{re.escape(permission)}'[\s\S]+?" rf"menu_type\s*<=>\s*'{menu_type}'" ) self.assertRegex(guard_sql, identity_pattern) self.assertRegex( guard_sql, r'from\s+sys_dict_data[\s\S]+?dict_code\s+between\s+33\s+and\s+69' r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'", ) self.assertRegex( guard_sql, r'from\s+sys_dict_type[\s\S]+?dict_id\s*=\s*12\b' r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'", ) dict_data_cleanup = re.search(r'delete\s+from\s+sys_dict_data\s+where\s+[^;]+', sql) dict_type_cleanup = re.search(r'delete\s+from\s+sys_dict_type\s+where\s+[^;]+', sql) self.assertRegex( dict_data_cleanup.group(), r'dict_code\s+between\s+33\s+and\s+69\s+and\s+dict_type\s*=\s*\'ai_provider_type\'', ) self.assertRegex( dict_type_cleanup.group(), r'dict_id\s*=\s*12\s+and\s+dict_type\s*=\s*\'ai_provider_type\'', ) if __name__ == '__main__': unittest.main()