From b30f3a28f54f29a2df78df91c7894451eb33f479 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Thu, 9 Jul 2026 18:59:22 +0200 Subject: [PATCH 1/2] fix: reshape update_opportunity_roles into Apollo's nested role payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint expects each role entry's type nested under a 'role' array: {contact_id, is_primary, role: [{opportunity_contact_role_type_id, is_primary}]}. The client sent opportunity_contact_role_type_id flat on the entry with no 'role' key, so Apollo ran .map on nil and 422'd with "undefined method 'map' for nil" — making 'deals set-role' fail every time. update_opportunity_roles now reshapes the flat RoleAssignment entries into the nested wire format (role type omitted from the nested object when absent). Public RoleAssignment interface is unchanged. Live-verified end-to-end: linked a contact + flipped role type, both persisted. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qodev_apollo_api/client.py | 18 +++++++++++++++++- tests/test_client.py | 20 ++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/qodev_apollo_api/client.py b/src/qodev_apollo_api/client.py index e862cde..16c8253 100644 --- a/src/qodev_apollo_api/client.py +++ b/src/qodev_apollo_api/client.py @@ -545,7 +545,23 @@ async def update_opportunity_roles( Returns: The updated Deal. """ - data = {"opportunity_id": opportunity_id, "roles": roles} + # Apollo's endpoint expects each entry's role type *nested* under a ``role`` + # array — sending ``opportunity_contact_role_type_id`` flat on the entry (with + # no ``role`` key) makes the server call ``.map`` on nil and 422 with + # "undefined method 'map' for nil". Reshape the flat RoleAssignment entries + # into the wire format the server actually accepts. + wire_roles: list[dict] = [] + for entry in roles: + is_primary = bool(entry.get("is_primary")) + role_obj: dict[str, Any] = {"is_primary": is_primary} + role_type_id = entry.get("opportunity_contact_role_type_id") + if role_type_id: + role_obj["opportunity_contact_role_type_id"] = role_type_id + wire_roles.append( + {"contact_id": entry["contact_id"], "is_primary": is_primary, "role": [role_obj]} + ) + + data = {"opportunity_id": opportunity_id, "roles": wire_roles} result = await self._post("/opportunities/update_roles", data) return Deal.model_validate(result.get("opportunity", result)) diff --git a/tests/test_client.py b/tests/test_client.py index 6406a78..8ffefa3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -643,7 +643,11 @@ async def test_list_opportunity_contact_role_types(client: ApolloClient): async def test_update_opportunity_roles(client: ApolloClient): - """Test POST /opportunities/update_roles returns the updated Deal with the roles body.""" + """The flat RoleAssignment entries are reshaped into Apollo's nested ``role`` wire format. + + Regression: sending ``opportunity_contact_role_type_id`` flat on the entry (no + ``role`` key) makes Apollo 422 with "undefined method 'map' for nil". + """ client._client.request.return_value = _make_response( {"opportunity": {"id": "d1", "name": "Big Deal"}} ) @@ -659,7 +663,19 @@ async def test_update_opportunity_roles(client: ApolloClient): call_args = client._client.request.call_args assert call_args[0] == ("POST", "/opportunities/update_roles") - assert call_args[1]["json"] == {"opportunity_id": "d1", "roles": roles} + assert call_args[1]["json"] == { + "opportunity_id": "d1", + "roles": [ + { + "contact_id": "c1", + "is_primary": True, + "role": [{"is_primary": True, "opportunity_contact_role_type_id": "rt1"}], + }, + # No role type → the nested role object carries only is_primary (never a + # flat/absent role type, which is what triggered the nil.map crash). + {"contact_id": "c2", "is_primary": False, "role": [{"is_primary": False}]}, + ], + } async def test_list_custom_fields(client: ApolloClient): From 441c64fb3d2ec641f11ab57dba759e04a835e392 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Thu, 9 Jul 2026 19:36:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20peqy=20review=20?= =?UTF-8?q?=E2=80=94=20use=20is=5Fprimary=20default=20instead=20of=20bool(?= =?UTF-8?q?)=20coercion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per peqy: bool(entry.get('is_primary')) would coerce a stray truthy non-bool (e.g. the string 'false') to True. RoleAssignment types is_primary as bool, so default to False when omitted and pass the value through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qodev_apollo_api/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/qodev_apollo_api/client.py b/src/qodev_apollo_api/client.py index 16c8253..bb895c3 100644 --- a/src/qodev_apollo_api/client.py +++ b/src/qodev_apollo_api/client.py @@ -552,7 +552,10 @@ async def update_opportunity_roles( # into the wire format the server actually accepts. wire_roles: list[dict] = [] for entry in roles: - is_primary = bool(entry.get("is_primary")) + # RoleAssignment types is_primary as a bool; default to False when omitted. + # Avoid bool(...) coercion, which would turn a stray truthy non-bool (e.g. + # the string "false") into True. + is_primary = entry.get("is_primary", False) role_obj: dict[str, Any] = {"is_primary": is_primary} role_type_id = entry.get("opportunity_contact_role_type_id") if role_type_id: