bmc_hub/tests/test_workflow_engine.py

92 lines
5.9 KiB
Python
Raw Normal View History

import pytest
from app.workflows.engine import evaluate, validate_definition, coverage_allows
def rule(method,priority=100,customer=None,condition=None,stop=False):
return {'id': 1,'priority':priority,'customer_id':customer,'definition':{'trigger':'time.created','stop_after_match':stop,'branches':[{'condition':condition,'actions':[{'type':'set_billing','method':method}]}]}}
def test_customer_precedes_global():
assert evaluate([rule('invoice',0),rule('subscription',100,'24')],{'customer_id':'24','agreements':[{'id':2}]},'time.created')['method']=='subscription'
def test_equal_priority_conflict_survives_stop():
result=evaluate([rule('invoice',stop=True),rule('internal')],{},'time.created')
assert result['status']=='manual_review'
assert result['reason']=='conflicting_rules'
def test_priority_and_scope():
assert evaluate([rule('internal',0,'wrong'),rule('invoice',10),rule('subscription',20)],{},'time.created')['method']=='invoice'
def test_nested_condition_and_else_if():
definition={'trigger':'time.created','branches':[{'condition':{'all':[{'field':'description','operator':'contains','value':'excel'},{'any':[{'field':'hours','operator':'gte','value':2},{'field':'tags','operator':'contains','value':5}]}]},'actions':[{'type':'set_billing','method':'subscription'}]},{'condition':None,'actions':[{'type':'set_billing','method':'invoice'}]}]}
result=evaluate([{'definition':definition}],{'description':'EXCEL support','hours':1,'tags':[5],'agreements':[{'id':2}]},'time.created')
assert result['method']=='subscription'
assert len(result['trace'])==1
def test_pending_tag_blocks_billing():
assert evaluate([rule('invoice')],{'pending_billing_tag_suggestions':True},'time.created')['reason']=='pending_tag_approval'
def test_default_requires_explicit_rule():
assert evaluate([],{},'time.created')['status']=='manual_review'
@pytest.mark.parametrize('definition',[{'trigger':'bad','branches':[]},{'trigger':'time.created','branches':[{'condition':{'field':'password','operator':'eq','value':'x'},'actions':[{'type':'set_billing','method':'invoice'}]}]},{'trigger':'time.created','branches':[{'actions':[{'type':'execute_sql'}]}]}])
def test_allowlist(definition):
with pytest.raises(ValueError): validate_definition(definition)
def test_simulator_does_not_mutate_inputs():
workflow=rule('invoice'); context={'description':'x'}
evaluate([workflow],context,'time.created')
assert context=={'description':'x'}
assert 'workflow_id' not in workflow['definition']['branches'][0]['actions'][0]
def test_resource_selection_is_unambiguous():
assert evaluate([rule('subscription')],{'agreements':[{'id':1},{'id':2}]},'time.created')['status']=='manual_review'
assert evaluate([rule('prepaid')],{'hours':2,'cards':[{'id':3,'remaining_hours':1}]},'time.created')['reason']=='insufficient_card_balance'
def test_coverage_exclusion_precedes_inclusion_and_descendants_opt_in():
context={'tag_ids':[1,2],'concrete_tag_ids':[2]}
assert not coverage_allows({'workflow_coverage':{'tag_ids':[1]}},context)
assert coverage_allows({'workflow_coverage':{'tag_ids':[1],'include_descendants':True}},context)
assert not coverage_allows({'workflow_coverage':{'tag_ids':[1],'excluded_tag_ids':[2],'include_descendants':True}},context)
assert coverage_allows({},context)
def test_condition_descendant_matching_is_explicit():
condition={'field':'tags','operator':'contains','value':1}
workflow=rule('invoice',condition=condition)
context={'tags':[1,2],'concrete_tag_ids':[2]}
assert evaluate([workflow],context,'time.created')['status']=='manual_review'
condition['include_descendants']=True
assert evaluate([workflow],context,'time.created')['method']=='invoice'
def test_prepaid_balance_uses_card_rounding():
context={'hours':0.1,'cards':[{'id':1,'remaining_hours':0.2,'rounding_minutes':15}]}
assert evaluate([rule('prepaid')],context,'time.created')['reason']=='insufficient_card_balance'
@pytest.mark.parametrize('operator,value',[('eq',24),('in',[24,25])])
def test_canonical_customer_id_numeric_condition(operator,value):
condition={'field':'customer_id','operator':operator,'value':value}
assert evaluate([rule('invoice',condition=condition)],{'customer_id':'24'},'time.created')['method']=='invoice'
def test_start_workflow_target_is_preserved_separately_from_source():
workflow={'id':2,'definition':{'trigger':'time.created','branches':[{'condition':None,'actions':[{'type':'start_workflow','workflow_id':7}]}]}}
action=evaluate([workflow],{},'time.created')['actions'][0]
assert action['workflow_id']==7
assert action['source_workflow_id']==2
def test_explicit_manual_review_stops_later_billing_rules():
workflow={'id':2,'priority':1,'definition':{'trigger':'time.created','branches':[{'condition':None,'actions':[{'type':'send_to_review','reason':'Exhausted card'}]}]}}
result=evaluate([workflow,rule('invoice',priority=2)],{},'time.created')
assert result['status']=='manual_review'
assert result['reason']=='explicit_manual_review'
assert result['review_reason']=='Exhausted card'
def test_missing_customer_mapping_never_becomes_internal_time():
result=evaluate([rule('internal')],{'customer_id':None,'customer_mapping_missing':True},'time.created')
assert result['status']=='manual_review'
assert result['reason']=='missing_customer_mapping'
def test_case_title_condition_is_distinct_from_case_id():
definition={'trigger':'case.updated','branches':[{'condition':{'field':'case_title','operator':'contains','value':'TEST'},'actions':[{'type':'notify','title':'Matched','recipient_user_ids':[1]}]}]}
validate_definition(definition)
result=evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'TEST tid'},'case.updated')
assert result['actions'][0]['title']=='Matched'
assert not evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'Printer'},'case.updated')['actions']