| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- """Child identity and in-flight workflow guards, using isolated SQLite only."""
- from datetime import date, datetime
- from decimal import Decimal
- import pytest
- from fastapi import HTTPException
- from sqlalchemy import create_engine
- from sqlalchemy.orm import sessionmaker
- from app.core.bx_auth import BxUserContext
- from app.models.bx_models import (
- BxAdvanceApply, BxAttachment, BxInvoice, BxReimburseApply,
- BxReimburseDetail, BxReimburseInvoice, BxWorkflowDef, BxWorkflowNode,
- )
- from app.models.pl_models import PlVehicleRechargeApply
- from app.schemas.bx.reimburse import ReimburseDetailCreate, ReimburseInvoiceLinkIn
- from app.schemas.bx.workflow import WorkflowNodeCreate
- from app.services.bx.reimburse import ReimburseService
- from app.services.bx.workflow import WorkflowService
- USER = BxUserContext('u', 'user', 'dept', 'super_admin', 'tenant')
- OLD = datetime(2020, 1, 1)
- @pytest.fixture(autouse=True)
- def forbid_business_database(monkeypatch):
- def forbidden(*args, **kwargs):
- raise AssertionError('business database connections are forbidden')
- monkeypatch.setattr('pymysql.connect', forbidden)
- @pytest.fixture
- def db():
- engine = create_engine('sqlite://')
- for model in (BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply,
- BxReimburseDetail, BxInvoice, BxAttachment, BxReimburseInvoice,
- PlVehicleRechargeApply):
- model.__table__.create(engine)
- with sessionmaker(bind=engine)() as session:
- yield session
- engine.dispose()
- def apply(db, id='a', tenant='tenant', status='draft'):
- row = BxReimburseApply(id=id, tenant_no=tenant, apply_code=id, title='custom',
- applicant_id='u', applicant_name='user', expense_id='e', expense_name='Expense', amount=30, status=status,
- reimburse_date=date(2026, 1, 1), wf_id='wf', create_by='u')
- db.add(row)
- db.flush()
- return row
- def detail(db, id, parent='a', tenant='tenant', amount=10):
- row = BxReimburseDetail(id=id, reimburse_id=parent, expense_id='e',
- expense_name='Expense', amount=amount, remark='reason', occur_date=date(2026, 1, 1),
- tenant_no=tenant, create_by='original', create_time=OLD, update_time=OLD)
- db.add(row)
- db.flush()
- return row
- def data(id=None, amount=10):
- return dict(id=id, expense_id='e', expense_name='Expense', amount=amount,
- remark='reason', occur_date=date(2026, 1, 1))
- def test_detail_diff_preserves_identity_metadata_and_fields(db):
- a = apply(db)
- old = detail(db, 'd1')
- removed = detail(db, 'd2', amount=20)
- new_line = {**data(amount=7), 'expense_id': 'new-expense'}
- ReimburseService.update_with_details(db, a.id, {}, [data('d1', 11), new_line], user=USER)
- live = db.query(BxReimburseDetail).filter_by(del_flag=0).all()
- assert old in live and old.id == 'd1' and old.create_time == OLD and old.create_by == 'original'
- assert old.amount == 11 and old.occur_date == date(2026, 1, 1) and old.remark == 'reason'
- assert removed.del_flag == 1 and len(live) == 2 and a.amount == Decimal('18')
- def test_detail_omitted_and_empty_have_distinct_meanings(db):
- a = apply(db)
- row = detail(db, 'd1')
- ReimburseService.update_with_details(db, a.id, {}, None, user=USER)
- assert row.del_flag == 0
- ReimburseService.update_with_details(db, a.id, {}, [], user=USER)
- db.refresh(row)
- assert row.del_flag == 1 and a.amount == 0
- @pytest.mark.parametrize('bad', ['foreign_parent', 'foreign_tenant', 'missing', 'duplicate', 'deleted'])
- def test_detail_bad_id_rejected_before_any_mutation(db, bad):
- a = apply(db)
- row = detail(db, 'd1')
- detail(db, 'foreign_parent', parent='other')
- detail(db, 'foreign_tenant', tenant='other')
- detail(db, 'deleted').del_flag = 1
- db.flush()
- payload = [data('d1'), data('d1' if bad == 'duplicate' else bad)]
- with pytest.raises(HTTPException):
- ReimburseService.update_with_details(db, a.id, {'remark': 'must not change'}, payload, user=USER)
- assert row.del_flag == 0 and a.remark is None
- assert db.query(BxReimburseDetail).count() == 4
- def test_legacy_detail_same_payload_preserves_identity_and_creation(db):
- a = apply(db)
- row = detail(db, 'd1')
- for _ in range(2):
- ReimburseService.update_with_details(db, a.id, {}, [data()], user=USER)
- assert row.del_flag == 0 and row.create_by == 'original' and row.create_time == OLD
- assert db.query(BxReimburseDetail).count() == 1
- @pytest.mark.parametrize('old_count,incoming_count', [(2, 1), (2, 2), (1, 2)])
- def test_legacy_repeated_expense_is_rejected_before_any_write(db, old_count, incoming_count):
- a = apply(db)
- for index in range(old_count):
- detail(db, f'd{index}', amount=10 + index)
- payload = [data(amount=10 + index) for index in range(incoming_count)]
- with pytest.raises(HTTPException) as error:
- ReimburseService.update_with_details(db, a.id, {'remark': 'must not change'}, payload, user=USER)
- assert error.value.status_code == 400
- assert a.remark is None and not db.dirty and not db.new
- @pytest.mark.parametrize('anonymous_first', [True, False])
- def test_mixed_explicit_and_legacy_ids_match_remaining_unique_expense(db, anonymous_first):
- a = apply(db)
- first, second = detail(db, 'd1'), detail(db, 'd2', amount=20)
- payload = [data('d1', 11), data(amount=21)]
- if anonymous_first:
- payload.reverse()
- ReimburseService.update_with_details(db, a.id, {}, payload, user=USER)
- assert first.amount == 11 and second.amount == 21
- assert first.del_flag == second.del_flag == 0
- assert db.query(BxReimburseDetail).count() == 2
- def test_explicit_existing_lines_allow_new_repeated_expense(db):
- a = apply(db)
- old = detail(db, 'd1')
- ReimburseService.update_with_details(db, a.id, {}, [data('d1'), data(amount=7)], user=USER)
- assert old.del_flag == 0
- assert db.query(BxReimburseDetail).filter_by(del_flag=0).count() == 2
- @pytest.mark.parametrize('explicit_null', [False, True])
- def test_detail_api_preserves_omitted_fields_but_applies_explicit_null(db, monkeypatch, explicit_null):
- from types import SimpleNamespace
- from app.api.bx.reimburse import _build_details_data
- a = apply(db)
- old = detail(db, 'd1')
- monkeypatch.setattr('app.api.bx.reimburse.ExpenseItemService.get_by_id',
- lambda *args, **kwargs: SimpleNamespace(item_name='Expense'))
- fields = {'remark': None, 'occurDate': None} if explicit_null else {}
- body = ReimburseDetailCreate(id='d1', expenseItemId='e', amount=11, **fields)
- payload = _build_details_data(db, 'tenant', [body], user=USER)
- ReimburseService.update_with_details(db, a.id, {}, payload, user=USER)
- assert old.amount == 11 and old.create_time == OLD
- assert old.remark == (None if explicit_null else 'reason')
- assert old.occur_date == (None if explicit_null else date(2026, 1, 1))
- def test_legacy_direct_patch_preserves_omitted_fields(db):
- a = apply(db)
- old = detail(db, 'd1')
- payload = dict(expense_id='e', expense_name='Expense', amount=11)
- ReimburseService.update_with_details(db, a.id, {}, [payload], user=USER)
- assert old.del_flag == 0 and old.amount == 11
- assert old.remark == 'reason' and old.occur_date == date(2026, 1, 1)
- def test_detail_schema_retains_id():
- assert ReimburseDetailCreate(id='d1', expenseItemId='e', amount=10).model_dump()['id'] == 'd1'
- def test_invoice_natural_key_diff_preserves_unchanged_links(db):
- apply(db)
- for id in ('i1', 'i2', 'i3'):
- db.add(BxInvoice(id=id, tenant_no='tenant', owner_user_id='u', owner_user_name='user', amount=10))
- old = BxReimburseInvoice(id='link1', reimburse_id='a', invoice_id='i1', tenant_no='tenant', create_time=OLD, create_by='original')
- removed = BxReimburseInvoice(id='link2', reimburse_id='a', invoice_id='i2', tenant_no='tenant')
- db.add_all([old, removed]); db.flush()
- ReimburseService.sync_reimburse_invoices(db, 'a', ['i1', 'i3'], 'tenant', USER, applicant_id='u')
- db.refresh(old); db.refresh(removed)
- assert old.del_flag == 0 and old.create_time == OLD and old.create_by == 'original'
- assert removed.del_flag == 1
- assert db.query(BxReimburseInvoice).count() == 3
- ReimburseService.sync_reimburse_invoices(db, 'a', ['i1', 'i3'], 'tenant', USER, applicant_id='u')
- assert db.query(BxReimburseInvoice).count() == 3
- def workflow(db):
- db.add(BxWorkflowDef(id='wf', biz_type='reimburse', wf_name='Workflow', tenant_no='tenant'))
- row = BxWorkflowNode(id='n1', wf_id='wf', tenant_no='tenant', node_name='Review',
- node_order=1, approver_id='u', approver_name='User', action_type='approve',
- create_by='original', create_time=OLD, update_time=OLD)
- db.add(row); db.flush()
- return row
- def node(id='n1', name='Review'):
- return dict(id=id, node_name=name, node_order=1, approver_id='u', approver_name='User', action_type='approve')
- @pytest.mark.parametrize('with_id', [True, False])
- def test_workflow_noop_preserves_node_even_with_pending(db, with_id):
- row = workflow(db); apply(db, status='pending')
- payload = node()
- if not with_id: payload.pop('id')
- saved = WorkflowService.save_nodes(db, 'wf', [payload], user=USER)
- assert saved[0].id == row.id == 'n1' and saved[0].create_time == OLD
- assert db.query(BxWorkflowNode).count() == 1
- @pytest.mark.parametrize('kind', ['reimburse', 'advance'])
- def test_workflow_change_rejected_for_pending_business(db, kind):
- row = workflow(db)
- if kind == 'reimburse': apply(db, status='pending')
- else:
- db.add(BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
- amount=1, reason='test', status='pending', wf_id='wf', tenant_no='tenant'))
- db.flush()
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
- assert row.node_name == 'Review' and row.del_flag == 0
- def test_workflow_diff_updates_existing_soft_deletes_removed(db):
- row = workflow(db)
- WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
- assert row.node_name == 'Changed' and row.create_time == OLD
- WorkflowService.save_nodes(db, 'wf', [], user=USER)
- assert row.del_flag == 1 and db.query(BxWorkflowNode).count() == 1
- @pytest.mark.parametrize('payload', [[node('missing')], [node(), node()], [node(), node(None)]])
- def test_workflow_rejects_unknown_duplicate_identity_before_changes(db, payload):
- row = workflow(db)
- with pytest.raises(ValueError): WorkflowService.save_nodes(db, 'wf', payload, user=USER)
- assert row.del_flag == 0 and db.query(BxWorkflowNode).count() == 1
- def test_workflow_input_schema_retains_node_id():
- assert WorkflowNodeCreate(**node()).model_dump()['id'] == 'n1'
- def test_workflow_metadata_change_and_delete_blocked_when_pending(db):
- workflow(db); apply(db, status='pending')
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.update(db, 'wf', {'status': 0}, user=USER)
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.soft_delete(db, 'wf', user=USER)
- def test_workflow_change_rejected_for_in_approval_recharge(db):
- row = workflow(db)
- db.get(BxWorkflowDef, 'wf').biz_type = 'vehicle_recharge'
- db.add(PlVehicleRechargeApply(id='r', apply_no='r', license_plate='test',
- recharge_type='etc', apply_date=date(2026, 1, 1), applicant_id='u',
- status='in_approval', wf_id='wf', current_node='n1', tenant_no='tenant'))
- db.flush()
- with pytest.raises(ValueError, match='审批'):
- WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
- assert row.node_name == 'Review'
- def test_workflow_api_returns_business_error_for_pending_change(db):
- from app.api.bx.workflow import update_workflow
- from app.schemas.bx.workflow import WorkflowDefUpdate
- workflow(db); apply(db, status='pending'); db.commit()
- with pytest.raises(HTTPException) as exc:
- update_workflow('wf', WorkflowDefUpdate(status=0), db=db, user=USER)
- assert exc.value.status_code == 400
- assert db.get(BxWorkflowDef, 'wf').status == 1
- def test_create_rejects_source_detail_id_without_inserting_parent(db):
- with pytest.raises(HTTPException):
- ReimburseService.create_with_details(db, {}, [data('source-id')], user=USER)
- assert db.query(BxReimburseApply).count() == 0
- def test_advance_submit_locks_definition_before_application(db, monkeypatch):
- from app.services.bx import advance as svc
- workflow(db)
- db.add(BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
- reason='test', amount=1, status='draft', wf_id='wf', tenant_no='tenant'))
- db.flush()
- locks = []
- original_lock = WorkflowService.lock_definition
- original_get = svc.AdvanceService.get_by_id
- def lock(*args, **kwargs):
- locks.append('definition')
- return original_lock(*args, **kwargs)
- def get(*args, **kwargs):
- if kwargs.get('for_update'): locks.append('application')
- return original_get(*args, **kwargs)
- monkeypatch.setattr(WorkflowService, 'lock_definition', lock)
- monkeypatch.setattr(svc.AdvanceService, 'get_by_id', get)
- monkeypatch.setattr(svc, 'sync_approval_notifications', lambda *args, **kwargs: None)
- result = svc.AdvanceService.submit(db, 'advance', USER)
- assert result.status == 'pending'
- assert locks[:2] == ['definition', 'application']
- @pytest.mark.parametrize('kind', ['advance', 'reimburse'])
- def test_submit_rejects_empty_configured_workflow_before_pending_or_invoice_debit(db, monkeypatch, kind):
- from app.services.bx import advance as advance_module
- from app.services.bx import reimburse as reimburse_module
- workflow(db).del_flag = 1
- if kind == 'advance':
- row = BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
- reason='test', amount=1, status='draft', wf_id='wf', tenant_no='tenant')
- db.add(row); db.flush()
- service = advance_module.AdvanceService
- monkeypatch.setattr(advance_module, 'sync_approval_notifications', lambda *args, **kwargs: None)
- else:
- row = apply(db)
- service = ReimburseService
- monkeypatch.setattr(service, '_has_debit_reimburse', lambda *args, **kwargs: True)
- monkeypatch.setattr(reimburse_module, 'sync_approval_notifications', lambda *args, **kwargs: None)
- with pytest.raises(HTTPException, match='节点'):
- service.submit(db, row.id, USER)
- assert row.status == 'draft' and row.current_node is None
|