"""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