diff --git a/src/ad_seller/services/approval_service.py b/src/ad_seller/services/approval_service.py index 47ba171..53f35f2 100644 --- a/src/ad_seller/services/approval_service.py +++ b/src/ad_seller/services/approval_service.py @@ -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": diff --git a/tests/unit/test_approval_gates.py b/tests/unit/test_approval_gates.py index 77a3c57..428e4fb 100644 --- a/tests/unit/test_approval_gates.py +++ b/tests/unit/test_approval_gates.py @@ -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.""" diff --git a/tests/unit/test_service_layer.py b/tests/unit/test_service_layer.py index a61dcad..174f990 100644 --- a/tests/unit/test_service_layer.py +++ b/tests/unit/test_service_layer.py @@ -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