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