bmc_hub/tests/test_workflow_export_guard.py

75 lines
3.7 KiB
Python

import asyncio
from contextlib import contextmanager
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app.workflows import export_guard as guard
def setup(monkeypatch,status='sending',result=None):
records=[]
monkeypatch.setattr(guard,'is_workflow_draft',lambda draft_id:True)
monkeypatch.setattr(guard,'claim',lambda draft_id,request:{'draft_id':draft_id,'reference':'stable','status':status,'result':result})
monkeypatch.setattr(guard,'record',lambda *args,**kwargs:records.append((args,kwargs)))
return records
def test_success_is_saved_before_caller_metadata(monkeypatch):
records=setup(monkeypatch)
exporter=AsyncMock(return_value={'success':True,'dry_run':False,'economic_draft_id':33})
result=asyncio.run(guard.guarded_export(1,exporter,{'lines':[{'line_key':'a','selected':True}]}))
assert records[0][0][1]=='done'
assert result['exported_line_keys']==['a']
assert exporter.call_args.kwargs['export_reference']=='stable'
def test_completed_export_replays_without_external_call(monkeypatch):
result={'economic_draft_id':33,'dry_run':False,'exported_line_keys':['old']}
records=setup(monkeypatch,'done',result)
exporter=AsyncMock()
replay=asyncio.run(guard.guarded_export(1,exporter,{'lines':[{'line_key':'new'}]}))
assert replay==result
assert replay is not result
exporter.assert_not_called()
assert records==[]
def test_timeout_is_uncertain_not_retryable(monkeypatch):
records=setup(monkeypatch)
exporter=AsyncMock(side_effect=asyncio.TimeoutError())
with pytest.raises(asyncio.TimeoutError): asyncio.run(guard.guarded_export(1,exporter,{}))
assert records[0][0][1]=='uncertain'
def test_definitive_preflight_failure_can_be_corrected(monkeypatch):
records=setup(monkeypatch)
exporter=AsyncMock(side_effect=HTTPException(422,'Invalid mapping before POST'))
with pytest.raises(HTTPException): asyncio.run(guard.guarded_export(1,exporter,{}))
assert records[0][0][1]=='pending'
def test_dry_run_never_completes_real_export(monkeypatch):
records=setup(monkeypatch)
exporter=AsyncMock(return_value={'success':True,'dry_run':True})
asyncio.run(guard.guarded_export(1,exporter,{}))
assert records[0][0][1]=='pending'
def test_legacy_draft_uses_existing_export_unchanged(monkeypatch):
monkeypatch.setattr(guard,'is_workflow_draft',lambda draft_id:False)
exporter=AsyncMock(return_value={'dry_run':True})
request={'customer_id':24,'export_reference':'original'}
asyncio.run(guard.guarded_export(1,exporter,request))
assert exporter.call_args.kwargs==request
@pytest.mark.parametrize('status',['sending','uncertain'])
def test_claim_blocks_concurrent_or_unknown_result_even_force(status,monkeypatch):
class Cursor:
def execute(self,*args): pass
def fetchone(self): return {'draft_id':1,'reference':'stable','status':status}
@contextmanager
def transaction(): yield Cursor()
monkeypatch.setattr(guard,'transaction',transaction)
with pytest.raises(HTTPException) as error: guard.claim(1,{'force_export':True})
assert error.value.status_code==409
def test_reconciliation_requires_exact_external_reference_and_line_values():
attempt={'draft_id':1,'request':{'currency':'DKK','lines':[{'description':'Support','quantity':1,'unit_price':500,'selected':True}]}}
remote={'references':{'other':'1'},'customer':{'customerNumber':24},'currency':'DKK','lines':[{'description':'Support','quantity':1,'unitNetPrice':500,'product':{'productNumber':'TIME'}}]}
guard.verify_remote(attempt,remote,24,['TIME'])
remote['references']['other']='different'
with pytest.raises(HTTPException): guard.verify_remote(attempt,remote,24,['TIME'])