Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/ad_seller/services/approval_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@ async def _resume_proposal_flow(request, response):

snapshot = request.flow_state_snapshot

# Re-hydrate state from snapshot
# Re-hydrate state from snapshot. CrewAI 1.15 made ``Flow.state`` a
# read-only property; assigning it raises AttributeError (HTTP 500).
# ``_state`` is the PrivateAttr the property reads.
flow = ProposalHandlingFlow()
flow.state = ProposalState(**snapshot)
flow._state = ProposalState(**(snapshot or {}))

# Apply the human decision
if response.decision == "approve":
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/test_approval_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,49 @@ async def test_unauthenticated_list_and_resume_rejected(self, client, mock_stora
assert list_resp.status_code == 401
assert resume_resp.status_code == 401

async def test_authenticated_resume_after_decide_returns_200(self, client, mock_storage):
"""Resume after a recorded decision must not 500 on CrewAI's read-only state."""
from ad_seller.events.models import ApprovalResponse

raw_key = _seed_key(mock_storage._store, key_id="key-resume", agency_id="agency-resume")
req = ApprovalRequest(
event_id="evt-resume",
flow_id="flow-resume",
flow_type="proposal_handling",
gate_name="proposal_decision",
proposal_id="prop-resume",
status=ApprovalStatus.APPROVED,
flow_state_snapshot={
"proposal_id": "prop-resume",
"flow_id": "flow-resume",
"flow_type": "proposal_handling",
},
)
mock_storage._store[f"approval:{req.approval_id}"] = req.model_dump(mode="json")
mock_storage._store[f"approval_response:{req.approval_id}"] = ApprovalResponse(
approval_id=req.approval_id,
decision="approve",
decided_by="lab",
).model_dump(mode="json")

with (
patch("ad_seller.storage.factory.get_storage", return_value=mock_storage),
patch(
"ad_seller.events.helpers.emit_event",
new_callable=AsyncMock,
),
):
resp = await client.post(
f"/approvals/{req.approval_id}/resume",
headers={"X-Api-Key": raw_key},
)

assert resp.status_code == 200
body = resp.json()
assert body["proposal_id"] == "prop-resume"
assert body["status"] == "accepted"
assert body["resumed_from_approval"] == req.approval_id

async def test_authenticated_decide_records_verified_principal(self, client, mock_storage):
"""A valid key authorizes the decision and the audit record stamps the
VERIFIED principal, not the arbitrary ``decided_by`` body value."""
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/test_service_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,75 @@ async def test_get_approval_not_found(self, mock_storage):
await approval_service.get_approval("apr-missing")
assert exc.value.status_code == 404

async def test_resume_after_approve_applies_decision(self, mock_storage):
"""Happy: resume after approve hydrates snapshot via _state, not flow.state."""
from ad_seller.events.models import ApprovalRequest, ApprovalResponse, ApprovalStatus

req = ApprovalRequest(
event_id="evt-1",
flow_id="flow-1",
flow_type="proposal_handling",
gate_name="proposal_decision",
proposal_id="prop-1",
status=ApprovalStatus.APPROVED,
flow_state_snapshot={
"proposal_id": "prop-1",
"flow_id": "flow-1",
"flow_type": "proposal_handling",
},
)
mock_storage._store[f"approval:{req.approval_id}"] = req.model_dump(mode="json")
mock_storage._store[f"approval_response:{req.approval_id}"] = ApprovalResponse(
approval_id=req.approval_id,
decision="approve",
decided_by="lab",
).model_dump(mode="json")

with (
patch("ad_seller.storage.factory.get_storage", return_value=mock_storage),
patch("ad_seller.events.helpers.emit_event", new_callable=AsyncMock),
):
result = await approval_service.resume_flow(req.approval_id)

assert result["proposal_id"] == "prop-1"
assert result["status"] == "accepted"
assert result["recommendation"] == "approve"
assert result["resumed_from_approval"] == req.approval_id

async def test_resume_after_reject_applies_decision(self, mock_storage):
"""Happy: reject path also resumes without assigning the read-only property."""
from ad_seller.events.models import ApprovalRequest, ApprovalResponse, ApprovalStatus

req = ApprovalRequest(
event_id="evt-2",
flow_id="flow-2",
flow_type="proposal_handling",
gate_name="proposal_decision",
proposal_id="prop-2",
status=ApprovalStatus.REJECTED,
flow_state_snapshot={
"proposal_id": "prop-2",
"flow_id": "flow-2",
"flow_type": "proposal_handling",
},
)
mock_storage._store[f"approval:{req.approval_id}"] = req.model_dump(mode="json")
mock_storage._store[f"approval_response:{req.approval_id}"] = ApprovalResponse(
approval_id=req.approval_id,
decision="reject",
decided_by="lab",
).model_dump(mode="json")

with (
patch("ad_seller.storage.factory.get_storage", return_value=mock_storage),
patch("ad_seller.events.helpers.emit_event", new_callable=AsyncMock),
):
result = await approval_service.resume_flow(req.approval_id)

assert result["proposal_id"] == "prop-2"
assert result["status"] == "rejected"
assert result["recommendation"] == "reject"


# =============================================================================
# session_service
Expand Down
Loading