| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- """Restore actual full-save/metadata semantics; UI handler and SQLite persistence."""
- import json
- from datetime import date, datetime
- from pathlib import Path
- import subprocess
- import pymysql
- import pytest
- from sqlalchemy import create_engine
- from sqlalchemy.orm import Session
- from app.models.models import SysRole, SysRoleDataScopeDetail, SysDept, SysUser
- from app.models.bx_models import BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply
- from app.core.bx_auth import BxUserContext
- from app.services.bx.workflow import WorkflowService
- USER = BxUserContext('u', 'User', 'd', 'super_admin', 't')
- @pytest.fixture
- def db(monkeypatch):
- def forbidden(*args, **kwargs):
- raise AssertionError('Business MySQL connections are forbidden')
- monkeypatch.setattr(pymysql, 'connect', forbidden)
- monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
- engine = create_engine('sqlite://')
- for model in (SysRole, SysRoleDataScopeDetail, SysDept, SysUser,
- BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply):
- model.__table__.create(engine)
- with Session(engine, autoflush=False) as session:
- yield session
- engine.dispose()
- def actual_scope_payload(departments, users=()):
- root = Path(__file__).parents[2]
- program = r'''
- const fs = require('node:fs')
- const ts = require(process.argv[1])
- const source = fs.readFileSync(process.argv[2], 'utf8').match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
- const ast = ts.createSourceFile('dialog.ts', source, ts.ScriptTarget.Latest, true)
- const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name.text === 'handleDataScopeSubmit')
- if (!fn) throw Error('handler missing')
- const js = ts.transpileModule(fn.getText(ast), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
- let payload
- const selections = JSON.parse(process.argv[3])
- const context = {
- dataScopeSubmitLoading: {value:false}, dataScopeForm:{dataScope:'5'},
- deptTreeRef:{value:{getCheckedKeys:()=>selections.departments}},
- scopeUserCheckedKeys:{value:selections.users}, dataScopeEditingRoleId:{value:'role'},
- fetchUpdateRoleDataScope:async (_id, data)=>{payload=data},
- dataScopeDialogVisible:{value:true}, emit:()=>{}, refreshViewIfOpen:async()=>{}
- }
- const run = new Function(...Object.keys(context), js+';return handleDataScopeSubmit')(...Object.values(context))
- run().then(()=>process.stdout.write(JSON.stringify(payload))).catch(e=>{console.error(e);process.exit(1)})
- '''
- result = subprocess.run(['node', '-e', program, str(root/'web-admin/node_modules/typescript'),
- str(root/'web-admin/src/components/business/role-manage-dialogs/index.vue'),
- json.dumps({'departments': departments, 'users': list(users)})],
- check=True, text=True, capture_output=True)
- return json.loads(result.stdout)
- def role_scope(db):
- db.add(SysRole(id='role', tenant_no='t', role_name='Role', data_scope='5'))
- db.add(SysDept(id='dept', tenant_no='t', dept_name='Department'))
- old = SysRoleDataScopeDetail(id='scope', tenant_no='t', role_id='role', scope_type='dept',
- scope_value='dept', create_by='creator', create_time=datetime(2020, 1, 1))
- foreign = SysRoleDataScopeDetail(id='foreign', tenant_no='other', role_id='role', scope_type='dept', scope_value='other-dept')
- db.add_all([old, foreign]); db.commit()
- return old, foreign
- def scope_route(db, monkeypatch, payload):
- from app.api import admin
- monkeypatch.setattr(admin, '_get_tenant_no', lambda *args: 't')
- return admin.update_role_data_scope('role', admin.RoleDataScopeBody.model_validate(payload),
- user={'user_id':'u'}, db=db)
- def test_actual_ui_clear_to_schema_route_removes_custom_scope(db, monkeypatch):
- old, foreign = role_scope(db)
- payload = actual_scope_payload([])
- assert payload == {'dataScope': '5'} # The current complete-save UI intentionally omits an empty list.
- result = scope_route(db, monkeypatch, payload)
- db.refresh(old); db.refresh(foreign)
- assert result['code'] == 200 and old.del_flag == 1 and foreign.del_flag == 0
- def test_actual_ui_unchanged_and_clear_restore_keep_relation_identity(db, monkeypatch):
- old, foreign = role_scope(db)
- for selected in (['dept'], [], ['dept'], ['dept']):
- result = scope_route(db, monkeypatch, actual_scope_payload(selected))
- assert result['code'] == 200
- db.refresh(old)
- assert old.del_flag == (0 if selected else 1)
- assert old.create_by == 'creator' and old.create_time == datetime(2020, 1, 1)
- assert db.query(SysRoleDataScopeDetail).filter_by(tenant_no='t').count() == 1
- assert foreign.del_flag == 0
- def test_explicit_empty_and_noncustom_scope_clear_still_supported(db, monkeypatch):
- old, _ = role_scope(db)
- assert scope_route(db, monkeypatch, {'dataScope':'5', 'scopeDetails':[]})['code'] == 200
- assert old.del_flag == 1
- assert scope_route(db, monkeypatch, {'dataScope':'1'})['code'] == 200
- assert old.del_flag == 1
- def test_foreign_scope_target_is_rejected_before_mutation(db, monkeypatch):
- old, foreign = role_scope(db)
- db.add(SysDept(id='foreign-dept', tenant_no='other', dept_name='Foreign')); db.commit()
- response = scope_route(db, monkeypatch, {'dataScope':'5', 'scopeDetails':[{'scopeType':'dept','scopeValue':'foreign-dept'}]})
- assert response['code'] == 400 and old.del_flag == foreign.del_flag == 0
- assert not db.dirty and not db.new
- def pending_workflow(db):
- workflow = BxWorkflowDef(id='wf', tenant_no='t', biz_type='reimburse', wf_name='Workflow', status=1)
- node = BxWorkflowNode(id='node', tenant_no='t', wf_id='wf', node_name='Review', node_order=1,
- action_type='approve', approver_id='u', approver_name='User', create_time=datetime(2020, 1, 1))
- application = BxReimburseApply(id='a', tenant_no='t', apply_code='A', title='Apply', applicant_id='u',
- applicant_name='User', expense_id='expense', expense_name='Expense', amount=10,
- reimburse_date=date(2026, 1, 1), status='pending', wf_id='wf', current_node='Review')
- db.add_all([workflow, node, application]); db.commit()
- return workflow, node, application
- def test_pending_workflow_name_update_through_real_schema_route_keeps_nodes(db, monkeypatch):
- from app.api.bx import workflow as api
- from app.schemas.bx.workflow import WorkflowDefUpdate
- workflow, node, application = pending_workflow(db)
- monkeypatch.setattr(api, 'get_tenant_no', lambda *args: 't')
- api.update_workflow('wf', WorkflowDefUpdate(wfName='Renamed'), db=db, user=USER)
- assert workflow.wf_name == 'Renamed'
- assert node.id == 'node' and node.node_name == 'Review' and node.create_time == datetime(2020, 1, 1)
- assert application.status == 'pending' and application.current_node == 'Review'
- assert db.query(BxWorkflowNode).count() == 1
- def test_pending_workflow_remark_and_unchanged_status_are_metadata_only(db):
- workflow, node, application = pending_workflow(db)
- result = WorkflowService.update(db, 'wf', {'remark':'Note', 'status':1, 'biz_type':'reimburse'}, user=USER)
- assert result.remark == 'Note' and node.node_name == 'Review' and application.status == 'pending'
- @pytest.mark.parametrize('patch', [{'wf_name':'Rename', 'status':0}, {'wf_name':'Rename', 'biz_type':'advance'}])
- def test_pending_structural_header_changes_still_rejected_atomically(db, patch):
- workflow, node, application = pending_workflow(db)
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.update(db, 'wf', patch, user=USER)
- assert workflow.wf_name == 'Workflow' and workflow.biz_type == 'reimburse' and workflow.status == 1
- assert not db.dirty and not db.new
- def test_pending_node_change_and_delete_guards_remain(db):
- workflow, node, application = pending_workflow(db)
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.save_nodes(db, 'wf', [{'id':'node', 'node_name':'Changed', 'node_order':1}], user=USER)
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.soft_delete(db, 'wf', user=USER)
- assert node.node_name == 'Review' and workflow.del_flag == 0 and not db.dirty and not db.new
|