qodev-apollo-api 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/CHANGELOG.md +28 -0
  2. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/__init__.py +7 -1
  5. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/client.py +51 -6
  6. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/models.py +56 -13
  7. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/utils.py +64 -1
  8. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_client.py +62 -7
  9. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_models.py +48 -6
  10. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_utils.py +62 -1
  11. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/uv.lock +1 -1
  12. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.github/workflows/ci.yml +0 -0
  13. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.github/workflows/publish.yml +0 -0
  14. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.gitignore +0 -0
  15. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.pre-commit-config.yaml +0 -0
  16. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/CLAUDE.md +0 -0
  17. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/LICENSE +0 -0
  18. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/Makefile +0 -0
  19. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/README.md +0 -0
  20. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/exceptions.py +0 -0
  21. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/py.typed +0 -0
  22. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/__init__.py +0 -0
  23. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/__init__.py +0 -0
  24. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/validate_all_models.py +0 -0
  25. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/validate_email_task_flow.py +0 -0
  26. {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_exceptions.py +0 -0
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-06-02
11
+
12
+ ### Fixed
13
+ - `create_note()` posted `{"note": <plaintext>}`, which Apollo silently ignores — notes were created with empty content. It now serialises `content` to ProseMirror JSON and posts it in the `content` field (the format Apollo stores and `search_notes()` reads back). Closes #6.
14
+
15
+ ### Added
16
+ - `markdown_to_prosemirror()` in `utils` — inverse of `prosemirror_to_markdown()` (title, paragraphs, bullet/ordered lists).
17
+ - `create_note(..., title=...)` — optional note title (rendered as the ProseMirror `noteTitle`).
18
+ - `ApolloClient.delete_note(note_id)` — `DELETE /notes/{id}`.
19
+
20
+ ### Changed
21
+ - `create_note()` association args (`contact_ids`, `account_ids`, `opportunity_ids`) are now keyword-only, so the new positional `title` can't be confused with them.
22
+
23
+ ## [0.1.3] - 2026-02-23
24
+
25
+ ### Added
26
+ - `ActionItemTask` subclass for `action_item` task type
27
+ - `OtherTask` fallback subclass — `resolve_task()` now returns `OtherTask` for unknown task types instead of raising `ValidationError`
28
+ - `OpportunityContactRoleType` model for role type definitions (Decision Maker, Buyer, etc.)
29
+ - `ApolloClient.list_opportunity_contact_role_types()` — lookup endpoint for role type ID → name mapping (undocumented `POST /opportunity_contact_role_types/search`)
30
+
31
+ ### Fixed
32
+ - `Deal.closed_date`, `Deal.actual_close_date`, `Deal.next_step_date` now parsed as `datetime` (were `str`)
33
+ - `EmploymentHistory.start_date`, `EmploymentHistory.end_date` now parsed as `date` (were `str`)
34
+ - `CallSummaryNextStep.due_at` now parsed as `datetime` (was `str`)
35
+ - `NewsArticle.published_at`, `JobPosting.posted_at` now parsed as `datetime` (were `str`)
36
+ - `search_conversations()` default and max limit corrected from 100 to 25 (Apollo API caps at 25)
37
+
10
38
  ## [0.1.2] - 2026-02-23
11
39
 
12
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for Apollo.io CRM API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-api
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-api
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-api"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,6 +9,7 @@ from .models import (
9
9
  AccountDetail,
10
10
  AccountPlaybookStatus,
11
11
  AccountQueue,
12
+ ActionItemTask,
12
13
  ApolloModel,
13
14
  BaseTask,
14
15
  CalendarEvent,
@@ -53,8 +54,10 @@ from .models import (
53
54
  NewsArticle,
54
55
  Note,
55
56
  OpportunityContactRole,
57
+ OpportunityContactRoleType,
56
58
  OpportunityRoleEntry,
57
59
  OrganizationRef,
60
+ OtherTask,
58
61
  OutreachTaskMessage,
59
62
  PaginatedResponse,
60
63
  PhoneEntry,
@@ -79,6 +82,7 @@ __all__ = [
79
82
  "AccountDetail",
80
83
  "AccountPlaybookStatus",
81
84
  "AccountQueue",
85
+ "ActionItemTask",
82
86
  "ApolloClient",
83
87
  "ApolloError",
84
88
  "ApolloModel",
@@ -126,8 +130,10 @@ __all__ = [
126
130
  "NewsArticle",
127
131
  "Note",
128
132
  "OpportunityContactRole",
133
+ "OpportunityContactRoleType",
129
134
  "OpportunityRoleEntry",
130
135
  "OrganizationRef",
136
+ "OtherTask",
131
137
  "OutreachTaskMessage",
132
138
  "PaginatedResponse",
133
139
  "PhoneEntry",
@@ -145,4 +151,4 @@ __all__ = [
145
151
  "resolve_task",
146
152
  ]
147
153
 
148
- __version__ = "0.1.2"
154
+ __version__ = "0.2.0"
@@ -27,6 +27,7 @@ from .models import (
27
27
  LinkedInConnectTask,
28
28
  LinkedInMessageTask,
29
29
  Note,
30
+ OpportunityContactRoleType,
30
31
  PaginatedResponse,
31
32
  Pipeline,
32
33
  SortOrder,
@@ -37,7 +38,7 @@ from .models import (
37
38
  TaskType,
38
39
  resolve_task,
39
40
  )
40
- from .utils import normalize_linkedin_url, prosemirror_to_markdown
41
+ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
41
42
 
42
43
 
43
44
  class ApolloClient:
@@ -493,6 +494,28 @@ class ApolloClient:
493
494
  page=1,
494
495
  )
495
496
 
497
+ async def list_opportunity_contact_role_types(
498
+ self,
499
+ ) -> PaginatedResponse[OpportunityContactRoleType]:
500
+ """List all opportunity contact role types (undocumented endpoint).
501
+
502
+ Returns role type definitions (e.g., Decision Maker, Buyer, Champion)
503
+ that map to OpportunityRoleEntry.opportunity_contact_role_type_id.
504
+
505
+ Returns:
506
+ Paginated response with OpportunityContactRoleType items
507
+ """
508
+ result = await self._post("/opportunity_contact_role_types/search", {})
509
+ role_types = [
510
+ OpportunityContactRoleType.model_validate(rt)
511
+ for rt in result.get("opportunity_contact_role_types", [])
512
+ ]
513
+ return PaginatedResponse[OpportunityContactRoleType](
514
+ items=role_types,
515
+ total=len(role_types),
516
+ page=1,
517
+ )
518
+
496
519
  # ========================================================================
497
520
  # ENRICHMENT
498
521
  # ========================================================================
@@ -574,14 +597,23 @@ class ApolloClient:
574
597
  async def create_note(
575
598
  self,
576
599
  content: str,
600
+ title: str | None = None,
601
+ *,
577
602
  contact_ids: list[str] | None = None,
578
603
  account_ids: list[str] | None = None,
579
604
  opportunity_ids: list[str] | None = None,
580
605
  ) -> dict:
581
606
  """Create a note.
582
607
 
608
+ Apollo stores note bodies in the ``content`` field as ProseMirror JSON,
609
+ so the plain text / Markdown ``content`` is converted via
610
+ :func:`~qodev_apollo_api.utils.markdown_to_prosemirror` before posting.
611
+ (Posting ``{"note": <plaintext>}`` is silently ignored by Apollo and
612
+ produces an empty note.)
613
+
583
614
  Args:
584
- content: Note content (plain text or Markdown)
615
+ content: Note body as plain text / Markdown.
616
+ title: Optional note title (rendered as the ProseMirror noteTitle).
585
617
  contact_ids: List of contact IDs to associate
586
618
  account_ids: List of account IDs to associate
587
619
  opportunity_ids: List of opportunity IDs to associate
@@ -589,7 +621,9 @@ class ApolloClient:
589
621
  Returns:
590
622
  Raw API response with created note data
591
623
  """
592
- data: dict[str, str | list[str]] = {"note": content}
624
+ data: dict[str, str | list[str]] = {
625
+ "content": markdown_to_prosemirror(content, title=title)
626
+ }
593
627
  if contact_ids:
594
628
  data["contact_ids"] = contact_ids
595
629
  if account_ids:
@@ -599,6 +633,17 @@ class ApolloClient:
599
633
 
600
634
  return await self._post("/notes", data)
601
635
 
636
+ async def delete_note(self, note_id: str) -> dict:
637
+ """Delete a note by ID.
638
+
639
+ Args:
640
+ note_id: Apollo note ID.
641
+
642
+ Returns:
643
+ Raw API response.
644
+ """
645
+ return await self._request("DELETE", f"/notes/{note_id}")
646
+
602
647
  # ========================================================================
603
648
  # ACTIVITIES
604
649
  # ========================================================================
@@ -1058,19 +1103,19 @@ class ApolloClient:
1058
1103
  # ========================================================================
1059
1104
 
1060
1105
  async def search_conversations(
1061
- self, page: int = 1, limit: int = 100, **filters
1106
+ self, page: int = 1, limit: int = 25, **filters
1062
1107
  ) -> PaginatedResponse[Conversation]:
1063
1108
  """Search recorded conversations (Zoom/Teams/Meet).
1064
1109
 
1065
1110
  Args:
1066
1111
  page: Page number (default 1)
1067
- limit: Results per page (default 100, max 100)
1112
+ limit: Results per page (default 25, max 25)
1068
1113
  **filters: Additional filters
1069
1114
 
1070
1115
  Returns:
1071
1116
  Paginated response with Conversation items
1072
1117
  """
1073
- data = {"page": page, "per_page": min(limit, 100), **filters}
1118
+ data = {"page": page, "per_page": min(limit, 25), **filters}
1074
1119
  result = await self._post("/conversations/search", data)
1075
1120
 
1076
1121
  conversations = [Conversation.model_validate(c) for c in result.get("conversations", [])]
@@ -6,12 +6,12 @@ All models use extra="allow" to capture any new/undocumented API fields.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from datetime import datetime
9
+ from datetime import date, datetime
10
10
  from decimal import Decimal
11
11
  from enum import StrEnum
12
12
  from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar
13
13
 
14
- from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter
14
+ from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter, ValidationError
15
15
 
16
16
 
17
17
  class ApolloModel(BaseModel):
@@ -183,6 +183,17 @@ class OpportunityContactRole(ApolloModel):
183
183
  updated_at: datetime | None = None
184
184
 
185
185
 
186
+ class OpportunityContactRoleType(ApolloModel):
187
+ """Role type definition for opportunity contact roles (e.g., Decision Maker, Buyer)."""
188
+
189
+ id: str
190
+ name: str | None = None
191
+ team_id: str | None = None
192
+ crm_api_name: str | None = None
193
+ crm_label: str | None = None
194
+ display_order: float | None = None
195
+
196
+
186
197
  class CrmNote(ApolloModel):
187
198
  """CRM-synced note reference."""
188
199
 
@@ -346,8 +357,8 @@ class EmploymentHistory(ApolloModel):
346
357
  title: str | None = None
347
358
  organization_name: str | None = None
348
359
  organization_id: str | None = None
349
- start_date: str | None = None
350
- end_date: str | None = None
360
+ start_date: date | None = None
361
+ end_date: date | None = None
351
362
 
352
363
  # Additional fields from API
353
364
  key: str | None = None
@@ -554,8 +565,8 @@ class Deal(ApolloModel):
554
565
  created_by_id: str | None = None
555
566
 
556
567
  # Close
557
- closed_date: str | None = None
558
- actual_close_date: str | None = None
568
+ closed_date: datetime | None = None
569
+ actual_close_date: datetime | None = None
559
570
  is_closed: bool | None = None
560
571
  is_won: bool | None = None
561
572
  closed_lost_reason: str | None = None
@@ -563,7 +574,7 @@ class Deal(ApolloModel):
563
574
 
564
575
  # Next Steps
565
576
  next_step: str | None = None
566
- next_step_date: str | None = None
577
+ next_step_date: datetime | None = None
567
578
  next_step_last_updated_at: datetime | None = None
568
579
  current_solutions: str | None = None
569
580
  deal_source: str | None = None
@@ -775,6 +786,8 @@ class TaskType(StrEnum):
775
786
  LINKEDIN_ACTIONS = "linkedin_actions"
776
787
  CONTACT_ACTION_ITEM = "contact_action_item"
777
788
  ACCOUNT_ACTION_ITEM = "account_action_item"
789
+ ACTION_ITEM = "action_item"
790
+ OTHER = "other"
778
791
 
779
792
 
780
793
  class LinkedInTemplate(ApolloModel):
@@ -919,6 +932,24 @@ class AccountActionItemTask(BaseTask):
919
932
  type: Literal[TaskType.ACCOUNT_ACTION_ITEM] = TaskType.ACCOUNT_ACTION_ITEM
920
933
 
921
934
 
935
+ class ActionItemTask(BaseTask):
936
+ """Generic action item task."""
937
+
938
+ type: Literal[TaskType.ACTION_ITEM] = TaskType.ACTION_ITEM
939
+
940
+
941
+ class OtherTask(BaseTask):
942
+ """Fallback for task types not yet modelled in the library.
943
+
944
+ Used by resolve_task() when the API returns a type value that doesn't
945
+ match any known TaskType variant. The raw type string from the API
946
+ is preserved in ``original_type``.
947
+ """
948
+
949
+ type: Literal[TaskType.OTHER] = TaskType.OTHER
950
+ original_type: str
951
+
952
+
922
953
  # ---------------------------------------------------------------------------
923
954
  # Discriminated union for polymorphic task deserialization
924
955
  # ---------------------------------------------------------------------------
@@ -934,7 +965,8 @@ _DiscriminatedTask = Annotated[
934
965
  | LinkedInViewProfileTask
935
966
  | LinkedInActionsTask
936
967
  | ContactActionItemTask
937
- | AccountActionItemTask,
968
+ | AccountActionItemTask
969
+ | ActionItemTask,
938
970
  Discriminator("type"),
939
971
  ]
940
972
 
@@ -950,6 +982,8 @@ Task: TypeAlias = (
950
982
  | LinkedInActionsTask
951
983
  | ContactActionItemTask
952
984
  | AccountActionItemTask
985
+ | ActionItemTask
986
+ | OtherTask
953
987
  )
954
988
 
955
989
  _task_adapter: TypeAdapter[_DiscriminatedTask] = TypeAdapter(_DiscriminatedTask)
@@ -958,9 +992,18 @@ _task_adapter: TypeAdapter[_DiscriminatedTask] = TypeAdapter(_DiscriminatedTask)
958
992
  def resolve_task(data: dict[str, Any]) -> Task:
959
993
  """Validate a raw task dict into the most specific Task subclass.
960
994
 
961
- Raises ValidationError if the type field is missing or unrecognized.
995
+ Falls back to OtherTask for unknown task types. The raw type string
996
+ is preserved in ``OtherTask.original_type``.
997
+ Raises ValidationError only if the data is structurally invalid
998
+ (e.g. missing required ``id`` field).
962
999
  """
963
- return _task_adapter.validate_python(data)
1000
+ try:
1001
+ return _task_adapter.validate_python(data)
1002
+ except ValidationError:
1003
+ raw_type = data.get("type")
1004
+ if raw_type is None:
1005
+ raise
1006
+ return OtherTask.model_validate({**data, "type": "other", "original_type": raw_type})
964
1007
 
965
1008
 
966
1009
  class Email(ApolloModel):
@@ -1127,7 +1170,7 @@ class CallSummaryNextStep(ApolloModel):
1127
1170
 
1128
1171
  id: str | None = None
1129
1172
  step: str | None = None
1130
- due_at: str | None = None
1173
+ due_at: datetime | None = None
1131
1174
  action_type: str | None = None
1132
1175
  task_id: str | None = None
1133
1176
  participant_id: str | None = None
@@ -1209,7 +1252,7 @@ class NewsArticle(BaseModel):
1209
1252
 
1210
1253
  title: str | None = None
1211
1254
  url: str | None = None
1212
- published_at: str | None = None
1255
+ published_at: datetime | None = None
1213
1256
  category: str | None = None
1214
1257
  summary: str | None = None
1215
1258
 
@@ -1221,7 +1264,7 @@ class JobPosting(BaseModel):
1221
1264
  location: str | None = None
1222
1265
  department: str | None = None
1223
1266
  url: str | None = None
1224
- posted_at: str | None = None
1267
+ posted_at: datetime | None = None
1225
1268
 
1226
1269
 
1227
1270
  # ============================================================================
@@ -1,9 +1,12 @@
1
1
  """Utility functions for Apollo client.
2
2
 
3
- Includes ProseMirror JSON to Markdown conversion for notes.
3
+ Includes ProseMirror JSON <-> Markdown conversion for notes.
4
4
  """
5
5
 
6
6
  import json
7
+ import re
8
+
9
+ _ORDERED_ITEM_RE = re.compile(r"^\d+\.\s+(.*)$")
7
10
 
8
11
 
9
12
  def prosemirror_to_markdown(content_json: str) -> tuple[str, str]:
@@ -103,6 +106,66 @@ def _extract_text_from_list_item(item: dict) -> str:
103
106
  return " ".join(text_parts)
104
107
 
105
108
 
109
+ def markdown_to_prosemirror(content: str, title: str | None = None) -> str:
110
+ """Convert plain text / lightweight Markdown to a ProseMirror JSON string.
111
+
112
+ Apollo's ``POST /notes`` stores the note body in the ``content`` field as a
113
+ ProseMirror JSON string; this is the inverse of :func:`prosemirror_to_markdown`
114
+ for the node types Apollo notes use.
115
+
116
+ Supports:
117
+ * an optional note title (``noteTitle`` node)
118
+ * paragraphs (one per line; blank lines become empty paragraphs)
119
+ * bullet lists (lines starting with ``- `` or ``* ``)
120
+ * ordered lists (lines starting with ``1. ``)
121
+
122
+ Args:
123
+ content: Note body as plain text / Markdown.
124
+ title: Optional note title (rendered as the ProseMirror ``noteTitle``).
125
+
126
+ Returns:
127
+ ProseMirror document as a JSON string, ready to post to ``content``.
128
+ """
129
+ nodes: list[dict] = []
130
+ if title:
131
+ nodes.append({"type": "noteTitle", "content": [{"type": "text", "text": title}]})
132
+
133
+ def _paragraph(text: str) -> dict:
134
+ # Empty text nodes are invalid in ProseMirror — emit a bare paragraph.
135
+ if not text:
136
+ return {"type": "paragraph"}
137
+ return {"type": "paragraph", "content": [{"type": "text", "text": text}]}
138
+
139
+ def _list_item(text: str) -> dict:
140
+ return {"type": "listItem", "content": [_paragraph(text)]}
141
+
142
+ lines = (content or "").split("\n")
143
+ i = 0
144
+ while i < len(lines):
145
+ stripped = lines[i].strip()
146
+
147
+ if stripped[:2] in ("- ", "* "):
148
+ items = []
149
+ while i < len(lines) and lines[i].strip()[:2] in ("- ", "* "):
150
+ items.append(_list_item(lines[i].strip()[2:].strip()))
151
+ i += 1
152
+ nodes.append({"type": "bulletList", "content": items})
153
+ continue
154
+
155
+ if _ORDERED_ITEM_RE.match(stripped):
156
+ items = []
157
+ while i < len(lines) and (m := _ORDERED_ITEM_RE.match(lines[i].strip())):
158
+ items.append(_list_item(m.group(1).strip()))
159
+ i += 1
160
+ nodes.append({"type": "orderedList", "content": items})
161
+ continue
162
+
163
+ nodes.append(_paragraph(lines[i]))
164
+ i += 1
165
+
166
+ return json.dumps({"type": "doc", "content": nodes}, ensure_ascii=False)
167
+
168
+
106
169
  def normalize_linkedin_url(url: str) -> str:
107
170
  """Normalize LinkedIn URL for comparison.
108
171
 
@@ -1,5 +1,6 @@
1
1
  """Tests for Apollo client methods."""
2
2
 
3
+ import json
3
4
  import os
4
5
  from datetime import UTC, datetime
5
6
  from unittest.mock import AsyncMock, MagicMock, patch
@@ -27,6 +28,7 @@ from qodev_apollo_api.models import (
27
28
  LinkedInConnectTask,
28
29
  LinkedInMessageTask,
29
30
  Note,
31
+ OpportunityContactRoleType,
30
32
  PaginatedResponse,
31
33
  Pipeline,
32
34
  SortOrder,
@@ -412,7 +414,21 @@ async def test_search_conversations(client: ApolloClient):
412
414
  assert len(result.items) == 1
413
415
  assert isinstance(result.items[0], Conversation)
414
416
 
415
- assert client._client.request.call_args[0] == ("POST", "/conversations/search")
417
+ call_args = client._client.request.call_args
418
+ assert call_args[0] == ("POST", "/conversations/search")
419
+ assert call_args[1]["json"]["per_page"] == 25
420
+
421
+
422
+ async def test_search_conversations_caps_limit(client: ApolloClient):
423
+ """Test search_conversations caps limit at 25 (Apollo API maximum)."""
424
+ client._client.request.return_value = _make_response(
425
+ {"conversations": [], "pagination": {"total_entries": 0}}
426
+ )
427
+
428
+ await client.search_conversations(limit=100)
429
+
430
+ payload = client._client.request.call_args[1]["json"]
431
+ assert payload["per_page"] == 25
416
432
 
417
433
 
418
434
  # ============================================================================
@@ -545,6 +561,31 @@ async def test_list_all_stages(client: ApolloClient):
545
561
  assert len(result.items) == 2
546
562
 
547
563
 
564
+ async def test_list_opportunity_contact_role_types(client: ApolloClient):
565
+ """Test POST /opportunity_contact_role_types/search returns role types."""
566
+ client._client.request.return_value = _make_response(
567
+ {
568
+ "opportunity_contact_role_types": [
569
+ {"id": "rt1", "name": "Decision Maker", "display_order": 3.0},
570
+ {"id": "rt2", "name": "Buyer", "display_order": 1.0},
571
+ ]
572
+ }
573
+ )
574
+
575
+ result = await client.list_opportunity_contact_role_types()
576
+
577
+ assert isinstance(result, PaginatedResponse)
578
+ assert len(result.items) == 2
579
+ assert all(isinstance(rt, OpportunityContactRoleType) for rt in result.items)
580
+ assert result.items[0].name == "Decision Maker"
581
+ assert result.items[1].name == "Buyer"
582
+ assert result.total == 2
583
+
584
+ call_args = client._client.request.call_args
585
+ assert call_args[0] == ("POST", "/opportunity_contact_role_types/search")
586
+ assert call_args[1]["json"] == {}
587
+
588
+
548
589
  async def test_get_contact_stages(client: ApolloClient):
549
590
  """Test GET /contact_stages returns list[dict]."""
550
591
  client._client.request.return_value = _make_response(
@@ -1069,18 +1110,23 @@ async def test_search_people(client: ApolloClient):
1069
1110
 
1070
1111
 
1071
1112
  async def test_create_note(client: ApolloClient):
1072
- """Test POST /notes."""
1073
- client._client.request.return_value = _make_response({"note": {"id": "n1", "content": "Hello"}})
1113
+ """create_note posts ProseMirror JSON in `content` (not plaintext in `note`)."""
1114
+ client._client.request.return_value = _make_response({"note": {"id": "n1"}})
1074
1115
 
1075
- result = await client.create_note("Hello")
1116
+ result = await client.create_note("Hello", title="Greeting")
1076
1117
 
1077
- assert result == {"note": {"id": "n1", "content": "Hello"}}
1118
+ assert result == {"note": {"id": "n1"}}
1078
1119
 
1079
1120
  call_args = client._client.request.call_args
1080
1121
  assert call_args[0] == ("POST", "/notes")
1081
1122
  payload = call_args[1]["json"]
1082
- assert payload["note"] == "Hello"
1123
+ assert "note" not in payload # old (ignored) field must be gone
1083
1124
  assert "contact_ids" not in payload
1125
+ doc = json.loads(payload["content"])
1126
+ assert doc["type"] == "doc"
1127
+ assert doc["content"][0]["type"] == "noteTitle"
1128
+ assert doc["content"][0]["content"][0]["text"] == "Greeting"
1129
+ assert doc["content"][1]["content"][0]["text"] == "Hello"
1084
1130
 
1085
1131
 
1086
1132
  async def test_create_note_with_associations(client: ApolloClient):
@@ -1095,12 +1141,21 @@ async def test_create_note_with_associations(client: ApolloClient):
1095
1141
  )
1096
1142
 
1097
1143
  payload = client._client.request.call_args[1]["json"]
1098
- assert payload["note"] == "Meeting notes"
1144
+ assert "Meeting notes" in payload["content"] # body serialised into ProseMirror
1099
1145
  assert payload["contact_ids"] == ["c1", "c2"]
1100
1146
  assert payload["account_ids"] == ["a1"]
1101
1147
  assert payload["opportunity_ids"] == ["o1"]
1102
1148
 
1103
1149
 
1150
+ async def test_delete_note(client: ApolloClient):
1151
+ """delete_note issues DELETE /notes/{id}."""
1152
+ client._client.request.return_value = _make_response({})
1153
+
1154
+ await client.delete_note("n1")
1155
+
1156
+ client._client.request.assert_called_once_with("DELETE", "/notes/n1")
1157
+
1158
+
1104
1159
  async def test_get_api_usage(client: ApolloClient):
1105
1160
  """Test POST /usage_stats/api_usage_stats."""
1106
1161
  usage_data = {
@@ -1,6 +1,6 @@
1
1
  """Tests for Apollo models."""
2
2
 
3
- from datetime import datetime
3
+ from datetime import UTC, datetime
4
4
 
5
5
  import pytest
6
6
  from pydantic import ValidationError
@@ -12,6 +12,7 @@ from qodev_apollo_api.models import (
12
12
  AccountDetail,
13
13
  AccountPlaybookStatus,
14
14
  AccountQueue,
15
+ ActionItemTask,
15
16
  ApolloModel,
16
17
  BaseTask,
17
18
  Call,
@@ -51,8 +52,10 @@ from qodev_apollo_api.models import (
51
52
  LinkedInViewProfileTask,
52
53
  Note,
53
54
  OpportunityContactRole,
55
+ OpportunityContactRoleType,
54
56
  OpportunityRoleEntry,
55
57
  OrganizationRef,
58
+ OtherTask,
56
59
  PaginatedResponse,
57
60
  PhoneEntry,
58
61
  Pipeline,
@@ -564,6 +567,36 @@ def test_deal_with_embedded_account():
564
567
  assert deal.account.name == "Acme Corp"
565
568
 
566
569
 
570
+ # ============================================================================
571
+ # OPPORTUNITY CONTACT ROLE TYPE
572
+ # ============================================================================
573
+
574
+
575
+ def test_opportunity_contact_role_type_model():
576
+ """Test OpportunityContactRoleType model validation."""
577
+ rt = OpportunityContactRoleType.model_validate(
578
+ {
579
+ "id": "rt1",
580
+ "name": "Decision Maker",
581
+ "team_id": "team1",
582
+ "crm_api_name": None,
583
+ "crm_label": None,
584
+ "display_order": 3.0,
585
+ }
586
+ )
587
+ assert rt.id == "rt1"
588
+ assert rt.name == "Decision Maker"
589
+ assert rt.display_order == 3.0
590
+
591
+
592
+ def test_opportunity_contact_role_type_minimal():
593
+ """Test OpportunityContactRoleType with only required fields."""
594
+ rt = OpportunityContactRoleType.model_validate({"id": "rt1"})
595
+ assert rt.id == "rt1"
596
+ assert rt.name is None
597
+ assert rt.display_order is None
598
+
599
+
567
600
  # ============================================================================
568
601
  # PIPELINE & STAGE
569
602
  # ============================================================================
@@ -811,6 +844,12 @@ def test_resolve_task_account_action_item():
811
844
  assert isinstance(result, AccountActionItemTask)
812
845
 
813
846
 
847
+ def test_resolve_task_action_item():
848
+ """Test resolve_task returns ActionItemTask."""
849
+ result = resolve_task({"id": "1", "type": "action_item"})
850
+ assert isinstance(result, ActionItemTask)
851
+
852
+
814
853
  def test_resolve_task_linkedin_interact():
815
854
  """Test resolve_task returns LinkedInInteractTask."""
816
855
  result = resolve_task({"id": "1", "type": "linkedin_step_interact_post"})
@@ -842,10 +881,13 @@ def test_resolve_task_missing_type_raises():
842
881
  resolve_task({"id": "1", "status": "complete"})
843
882
 
844
883
 
845
- def test_resolve_task_unknown_type_raises():
846
- """Test resolve_task raises ValidationError for unknown type values."""
847
- with pytest.raises(ValidationError):
848
- resolve_task({"id": "1", "type": "some_future_task_type"})
884
+ def test_resolve_task_unknown_type_falls_back_to_other_task():
885
+ """Test resolve_task falls back to OtherTask for unknown type values."""
886
+ result = resolve_task({"id": "1", "type": "some_future_task_type"})
887
+ assert isinstance(result, OtherTask)
888
+ assert result.type == TaskType.OTHER
889
+ assert result.original_type == "some_future_task_type"
890
+ assert result.id == "1"
849
891
 
850
892
 
851
893
  def test_task_with_full_emailer_message():
@@ -1036,7 +1078,7 @@ def test_call_summary_with_structured_items():
1036
1078
  assert len(summary.next_steps) == 1
1037
1079
  assert isinstance(summary.next_steps[0], CallSummaryNextStep)
1038
1080
  assert summary.next_steps[0].step == "Schedule demo"
1039
- assert summary.next_steps[0].due_at == "2026-02-24T00:00:00Z"
1081
+ assert summary.next_steps[0].due_at == datetime(2026, 2, 24, tzinfo=UTC)
1040
1082
  assert len(summary.pain_points) == 1
1041
1083
  assert isinstance(summary.pain_points[0], CallSummaryPoint)
1042
1084
  assert summary.pain_points[0].text == "Catching bugs early"
@@ -2,7 +2,11 @@
2
2
 
3
3
  import json
4
4
 
5
- from qodev_apollo_api.utils import normalize_linkedin_url, prosemirror_to_markdown
5
+ from qodev_apollo_api.utils import (
6
+ markdown_to_prosemirror,
7
+ normalize_linkedin_url,
8
+ prosemirror_to_markdown,
9
+ )
6
10
 
7
11
 
8
12
  def test_prosemirror_to_markdown_simple():
@@ -111,3 +115,60 @@ def test_normalize_linkedin_url_empty():
111
115
  """Test normalization of empty URL."""
112
116
  assert normalize_linkedin_url("") == ""
113
117
  assert normalize_linkedin_url(None) == ""
118
+
119
+
120
+ def test_markdown_to_prosemirror_simple():
121
+ """Title + body become a noteTitle node and a paragraph node."""
122
+ doc = json.loads(markdown_to_prosemirror("Hello world", title="My Title"))
123
+ assert doc["type"] == "doc"
124
+ assert doc["content"][0] == {
125
+ "type": "noteTitle",
126
+ "content": [{"type": "text", "text": "My Title"}],
127
+ }
128
+ assert doc["content"][1] == {
129
+ "type": "paragraph",
130
+ "content": [{"type": "text", "text": "Hello world"}],
131
+ }
132
+
133
+
134
+ def test_markdown_to_prosemirror_blank_line_is_empty_paragraph():
135
+ """Blank lines become bare paragraphs (empty text nodes are invalid)."""
136
+ doc = json.loads(markdown_to_prosemirror("a\n\nb"))
137
+ assert doc["content"] == [
138
+ {"type": "paragraph", "content": [{"type": "text", "text": "a"}]},
139
+ {"type": "paragraph"},
140
+ {"type": "paragraph", "content": [{"type": "text", "text": "b"}]},
141
+ ]
142
+
143
+
144
+ def test_markdown_to_prosemirror_no_title():
145
+ """Without a title there is no noteTitle node."""
146
+ doc = json.loads(markdown_to_prosemirror("body only"))
147
+ assert all(n["type"] != "noteTitle" for n in doc["content"])
148
+
149
+
150
+ def test_markdown_to_prosemirror_lists():
151
+ """Bullet and ordered list lines become bulletList / orderedList nodes."""
152
+ doc = json.loads(markdown_to_prosemirror("- one\n- two\n1. first\n2. second"))
153
+ types = [n["type"] for n in doc["content"]]
154
+ assert types == ["bulletList", "orderedList"]
155
+ assert len(doc["content"][0]["content"]) == 2 # two bullet items
156
+ assert doc["content"][0]["content"][0]["content"][0]["content"][0]["text"] == "one"
157
+ assert doc["content"][1]["content"][1]["content"][0]["content"][0]["text"] == "second"
158
+
159
+
160
+ def test_markdown_prosemirror_roundtrip():
161
+ """markdown_to_prosemirror output is readable back by prosemirror_to_markdown."""
162
+ pm = markdown_to_prosemirror("First para\n\nSecond para", title="Round Trip")
163
+ title, markdown = prosemirror_to_markdown(pm)
164
+ assert title == "Round Trip"
165
+ assert markdown == "First para\n\nSecond para"
166
+
167
+
168
+ def test_markdown_to_prosemirror_empty():
169
+ """Empty content yields a valid empty doc."""
170
+ doc = json.loads(markdown_to_prosemirror(""))
171
+ assert doc["type"] == "doc"
172
+ # No invalid empty text nodes anywhere.
173
+ for node in doc["content"]:
174
+ assert node.get("content") != [{"type": "text", "text": ""}]
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.1.1"
410
+ version = "0.1.3"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },