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']