Files
shanxia2.0/backend/tests/test_bx_child_differential_save.py
T

325 lines
14 KiB
Python

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