From d583225ab94d132cd57e9b8eed411827f2c46202 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Thu, 28 May 2026 12:46:36 +0200 Subject: [PATCH] feat: add campaigns endpoints --- CHANGELOG.md | 19 ++- README.md | 32 ++++ pyproject.toml | 2 +- src/lettr/__init__.py | 20 ++- src/lettr/_client.py | 6 +- src/lettr/_types.py | 94 +++++++++++ src/lettr/_version.py | 17 ++ src/lettr/resources/__init__.py | 3 +- src/lettr/resources/campaigns.py | 219 ++++++++++++++++++++++++++ tests/test_campaigns.py | 258 +++++++++++++++++++++++++++++++ 10 files changed, 662 insertions(+), 8 deletions(-) create mode 100644 src/lettr/_version.py create mode 100644 src/lettr/resources/campaigns.py create mode 100644 tests/test_campaigns.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7e8a2..729f7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-05-27 + +### Added +- Campaigns API — wraps the `/campaigns` endpoints under `client.campaigns`: + - `list` — paginated, with an optional `status` filter + - `get` — single campaign including rendered `html_content` + - `list_events` — cursor-paginated engagement events, with + `event_type` / `email` / `start_date` / `end_date` / `limit` filters + - `send` — send a campaign now + - `schedule` — schedule a campaign (`scheduled_at`, ISO 8601, future) + - `unschedule` — cancel a scheduled send + `send` / `schedule` / `unschedule` return the updated `Campaign`, or `None` + in the rare case the API omits it (e.g. the campaign was concurrently + deleted). + ## [1.2.0] - 2026-05-26 ### Added @@ -157,7 +172,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`, `ServerError`) -[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/lettr/lettr-python/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/lettr/lettr-python/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/lettr/lettr-python/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/lettr/lettr-python/compare/v0.3.0...v1.0.0 [0.3.0]: https://github.com/lettr/lettr-python/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 56d7d90..5579dc8 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,38 @@ for project in project_list.projects: print(f"{project.emoji} {project.name}") ``` +### Campaigns + +Campaigns are authored in the Lettr app; the SDK lists them, reads them, +inspects their engagement events, and triggers delivery. + +```python +# List campaigns (optionally filter by status) +page = client.campaigns.list(status="sent", per_page=50) +for campaign in page.campaigns: + print(f"{campaign.name}: {campaign.status} — {campaign.stats.unique_opens} opens") + +# Get a single campaign, including rendered HTML +campaign = client.campaigns.get(page.campaigns[0].id) +print(campaign.html_content) + +# List engagement events (cursor-based pagination) +events = client.campaigns.list_events(campaign.id, event_type="open") +for event in events.events: + print(f"{event.timestamp} {event.event_type} {event.email}") +if events.next_cursor: + more = client.campaigns.list_events(campaign.id, cursor=events.next_cursor) + +# Send now +client.campaigns.send(campaign.id) + +# Schedule for later (ISO 8601, must be in the future) +client.campaigns.schedule(campaign.id, scheduled_at="2026-06-01T09:00:00+00:00") + +# Cancel a scheduled send +client.campaigns.unschedule(campaign.id) +``` + ## Error Handling The SDK raises typed exceptions for all API errors: diff --git a/pyproject.toml b/pyproject.toml index 5e8103a..ad10ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "1.2.0" +version = "1.3.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index 0751f4f..da782f6 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -48,6 +48,11 @@ BulkDeleteResult, BulkListsAttachResult, BulkListsDetachResult, + Campaign, + CampaignEvent, + CampaignEventPage, + CampaignPage, + CampaignStats, DkimInfo, DmarcValidationResult, DnsProvider, @@ -76,11 +81,12 @@ UserAgentParsed, Webhook, ) -from .resources import Audience, Domains, Emails, Projects, Templates, Webhooks - -__version__ = "1.2.0" +from ._version import __version__ +from .resources import Audience, Campaigns, Domains, Emails, Projects, Templates, Webhooks __all__ = [ + # Version + "__version__", # Client "Lettr", # Exceptions @@ -114,6 +120,11 @@ "BulkDeleteResult", "BulkListsAttachResult", "BulkListsDetachResult", + "Campaign", + "CampaignEvent", + "CampaignEventPage", + "CampaignPage", + "CampaignStats", "DkimInfo", "DmarcValidationResult", "DnsProvider", @@ -210,6 +221,9 @@ def __init__( self.audience = Audience(self._client) """Audience management — lists, contacts, topics, properties, segments.""" + self.campaigns = Campaigns(self._client) + """Campaign operations — list, get, events, send, schedule, unschedule.""" + def health(self) -> HealthCheck: """Check API health. No authentication required. diff --git a/src/lettr/_client.py b/src/lettr/_client.py index 456cbeb..36e4867 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -7,9 +7,11 @@ import httpx from ._exceptions import LettrError, raise_for_status +from ._version import __version__ DEFAULT_BASE_URL = "https://app.lettr.com/api" DEFAULT_TIMEOUT = 30.0 +USER_AGENT = f"lettr-python/{__version__}" class ApiClient: @@ -31,7 +33,7 @@ def __init__( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "lettr-python/1.2.0", + "User-Agent": USER_AGENT, }, ) @@ -107,7 +109,7 @@ def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any timeout=self._timeout, headers={ "Accept": "application/json", - "User-Agent": "lettr-python/1.2.0", + "User-Agent": USER_AGENT, }, ) except httpx.HTTPError as exc: diff --git a/src/lettr/_types.py b/src/lettr/_types.py index ba3de80..72a5e42 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -47,6 +47,22 @@ def _from_dict(cls: type[T], data: dict[str, Any]) -> T: return cls(**{k: v for k, v in data.items() if k in known}) +def _pagination_kwargs(p: dict[str, Any]) -> dict[str, int]: + """Extract the standard four-field page-based pagination kwargs. + + The Laravel-style paginator returns ``{total, per_page, current_page, + last_page}`` on every list endpoint. Helper exists so each ``*Page`` + parser unpacks the same four keys consistently rather than spelling them + out by hand. + """ + return { + "total": p["total"], + "per_page": p["per_page"], + "current_page": p["current_page"], + "last_page": p["last_page"], + } + + # --------------------------------------------------------------------------- # Common / shared types # --------------------------------------------------------------------------- @@ -648,3 +664,81 @@ class BulkListsDetachResult: detached: int not_present: int total_pairs: int + + +# --------------------------------------------------------------------------- +# Campaigns +# --------------------------------------------------------------------------- + + +@dataclass +class CampaignStats: + """Aggregated engagement statistics for a campaign.""" + + injections: int + deliveries: int + bounces: int + spam_complaints: int + opens: int + unique_opens: int + clicks: int + unique_clicks: int + unsubscribes: int + + +@dataclass +class Campaign: + """A campaign with embedded engagement stats. + + ``html_content`` is populated by ``client.campaigns.get(id)`` (which + returns the full rendered email body) and left as ``None`` on + ``client.campaigns.list()`` responses, which omit the heavy field. + """ + + id: str + name: str + status: str + sent_count: int + created_at: str + stats: CampaignStats + subject: str | None = None + from_email: str | None = None + from_name: str | None = None + reply_to: str | None = None + scheduled_at: str | None = None + total_recipients: int | None = None + sent_at: str | None = None + html_content: str | None = None + + +@dataclass +class CampaignPage: + """Paginated list of campaigns.""" + + campaigns: list[Campaign] + total: int + per_page: int + current_page: int + last_page: int + + +@dataclass +class CampaignEvent: + """A single campaign engagement event.""" + + event_id: str + event_type: str + email: str + timestamp: str + bounce_class: str | None = None + reason: str | None = None + target_link_url: str | None = None + user_agent: str | None = None + + +@dataclass +class CampaignEventPage: + """Cursor-paginated list of campaign events.""" + + events: list[CampaignEvent] + next_cursor: str | None = None diff --git a/src/lettr/_version.py b/src/lettr/_version.py new file mode 100644 index 0000000..cef291c --- /dev/null +++ b/src/lettr/_version.py @@ -0,0 +1,17 @@ +"""Single source of truth for the package version. + +The version itself is declared in ``pyproject.toml``; this module reads it +back at runtime via :func:`importlib.metadata.version` so that +:data:`__version__` and the HTTP ``User-Agent`` header stay in sync with the +installed distribution automatically. No other file should hardcode the +version string. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__: str = version("lettr") +except PackageNotFoundError: # pragma: no cover — running from an uninstalled checkout + __version__ = "0.0.0+unknown" diff --git a/src/lettr/resources/__init__.py b/src/lettr/resources/__init__.py index 2b983c3..4705c53 100644 --- a/src/lettr/resources/__init__.py +++ b/src/lettr/resources/__init__.py @@ -1,10 +1,11 @@ """Lettr API resource modules.""" from .audience import Audience +from .campaigns import Campaigns from .domains import Domains from .emails import Emails from .projects import Projects from .templates import Templates from .webhooks import Webhooks -__all__ = ["Audience", "Domains", "Emails", "Projects", "Templates", "Webhooks"] +__all__ = ["Audience", "Campaigns", "Domains", "Emails", "Projects", "Templates", "Webhooks"] diff --git a/src/lettr/resources/campaigns.py b/src/lettr/resources/campaigns.py new file mode 100644 index 0000000..3ff5f21 --- /dev/null +++ b/src/lettr/resources/campaigns.py @@ -0,0 +1,219 @@ +"""Campaign management — list, inspect, and send/schedule campaigns.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from .._client import ApiClient +from .._types import ( + Campaign, + CampaignEvent, + CampaignEventPage, + CampaignPage, + CampaignStats, + _from_dict, + _pagination_kwargs, +) + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def _parse_campaign(d: dict[str, Any]) -> Campaign: + """Parse a campaign payload (CampaignSummary or CampaignDetail). + + The ``html_content`` field is only present on the detail (``get``) + response; for list responses it stays ``None``. Unknown keys are + silently dropped via :func:`_from_dict` so the parser is + forward-compatible. + """ + return _from_dict( + Campaign, + {**d, "stats": _from_dict(CampaignStats, d["stats"])}, + ) + + +def _parse_event(d: dict[str, Any]) -> CampaignEvent: + return _from_dict(CampaignEvent, d) + + +def _parse_action_response(body: dict[str, Any]) -> Campaign | None: + """Parse the optional ``data`` campaign from an action response. + + The ``CampaignActionResponse`` schema makes ``data`` optional — the + server omits it in the rare case the campaign can't be re-read after + the action (e.g. it was concurrently deleted). ``None`` here means + "absent key", not "empty object": a spec-violating ``data: {}`` is + intentionally left to raise inside :func:`_parse_campaign` rather than + being silently swallowed. + """ + data = body.get("data") + return _parse_campaign(data) if data is not None else None + + +# --------------------------------------------------------------------------- +# Campaigns +# --------------------------------------------------------------------------- + + +class Campaigns: + """Operations for campaigns. + + Campaigns are read-only over the API (no create/update/delete); they are + authored in the Lettr app. This resource lists them, reads a single + campaign, inspects engagement events, and triggers delivery. + + Usage:: + + page = client.campaigns.list(status="sent") + campaign = client.campaigns.get(page.campaigns[0].id) + events = client.campaigns.list_events(campaign.id, event_type="open") + client.campaigns.schedule(campaign.id, scheduled_at="2026-06-01T09:00:00+00:00") + """ + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + page: int | None = None, + per_page: int | None = None, + status: str | None = None, + ) -> CampaignPage: + """List campaigns with pagination. + + Args: + page: Page number (1-based). + per_page: Results per page (1-100, default 20). + status: Filter by status — one of ``draft``, ``scheduled``, + ``preparing``, ``in_review``, ``sending``, ``sent``, ``failed``. + + Returns: + A :class:`CampaignPage` with campaigns and pagination info. + """ + params: dict[str, Any] = {} + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + if status is not None: + params["status"] = status + + body = self._client.get("/campaigns", params=params) + data = body["data"] + return CampaignPage( + campaigns=[_parse_campaign(item) for item in data["campaigns"]], + **_pagination_kwargs(data["pagination"]), + ) + + def get(self, campaign_id: str) -> Campaign: + """Get a single campaign by ID, including its rendered HTML content. + + The returned :class:`Campaign` has ``html_content`` populated; list + responses leave it as ``None``. + """ + body = self._client.get(f"/campaigns/{campaign_id}") + return _parse_campaign(body["data"]) + + def list_events( + self, + campaign_id: str, + *, + event_type: str | None = None, + email: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> CampaignEventPage: + """List engagement events for a campaign with cursor-based pagination. + + Args: + campaign_id: The campaign ID. + event_type: Filter by event type — one of ``injection``, + ``delivery``, ``bounce``, ``spam_complaint``, ``open``, + ``click``, ``list_unsubscribe``. + email: Filter by recipient email address. + start_date: Only events on or after this time (ISO 8601). + end_date: Only events on or before this time (ISO 8601). + limit: Results per page (1-100, default 25). + cursor: Pagination cursor from a previous response. + + Returns: + A :class:`CampaignEventPage` with events and the next cursor. + """ + params: dict[str, Any] = {} + if event_type is not None: + params["event_type"] = event_type + if email is not None: + params["email"] = email + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + if limit is not None: + params["limit"] = limit + if cursor is not None: + params["cursor"] = cursor + + body = self._client.get(f"/campaigns/{campaign_id}/events", params=params) + data = body["data"] + # next_cursor is `required` in the OpenAPI schema (nullable but + # always present), so use [] — a missing key here is a server + # regression we want to surface, not silently treat as end-of-pages. + return CampaignEventPage( + events=[_parse_event(item) for item in data["events"]], + next_cursor=data["next_cursor"], + ) + + def send(self, campaign_id: str) -> Campaign | None: + """Send a campaign immediately. + + Returns: + The updated :class:`Campaign`, or ``None`` if the API does not + return the campaign (e.g. it was concurrently deleted). + """ + body = self._client.post(f"/campaigns/{campaign_id}/send") + return _parse_action_response(body) + + def schedule( + self, + campaign_id: str, + *, + scheduled_at: datetime | str, + ) -> Campaign | None: + """Schedule a campaign for future delivery. + + Args: + campaign_id: The campaign ID. + scheduled_at: Future delivery time. Accepts either an ISO 8601 + string or a :class:`~datetime.datetime` (which is rendered + via ``.isoformat()``). Include a timezone offset + (e.g. ``+02:00`` or ``Z``); naive values are interpreted as + UTC. Must be in the future. + + Returns: + The updated :class:`Campaign`, or ``None`` if the API does not + return the campaign. + """ + scheduled_at_str = ( + scheduled_at.isoformat() if isinstance(scheduled_at, datetime) else scheduled_at + ) + body = self._client.post( + f"/campaigns/{campaign_id}/schedule", + json={"scheduled_at": scheduled_at_str}, + ) + return _parse_action_response(body) + + def unschedule(self, campaign_id: str) -> Campaign | None: + """Cancel a campaign's scheduled delivery, returning it to draft. + + Returns: + The updated :class:`Campaign`, or ``None`` if the API does not + return the campaign. + """ + body = self._client.post(f"/campaigns/{campaign_id}/unschedule") + return _parse_action_response(body) diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py new file mode 100644 index 0000000..0af1502 --- /dev/null +++ b/tests/test_campaigns.py @@ -0,0 +1,258 @@ +"""Tests for the Campaigns resource.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from lettr._types import ( + Campaign, + CampaignEventPage, + CampaignPage, + CampaignStats, +) +from lettr.resources.campaigns import Campaigns + +# --------------------------------------------------------------------------- +# Fixtures & data +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def campaigns(mock_client: MagicMock) -> Campaigns: + return Campaigns(mock_client) + + +STATS_DATA = { + "injections": 100, + "deliveries": 98, + "bounces": 2, + "spam_complaints": 1, + "opens": 60, + "unique_opens": 45, + "clicks": 20, + "unique_clicks": 15, + "unsubscribes": 3, +} + +CAMPAIGN_DATA = { + "id": "camp_1", + "name": "Spring Sale", + "status": "sent", + "sent_count": 124, + "created_at": "2026-05-01T09:00:00+00:00", + "stats": STATS_DATA, + "subject": "Big news", + "from_email": "hi@example.com", + "from_name": "Example", + "reply_to": "reply@example.com", + "scheduled_at": None, + "total_recipients": 124, + "sent_at": "2026-05-01T10:00:00+00:00", +} + +# Detail payload (what `get` returns) — same as CAMPAIGN_DATA plus html_content. +CAMPAIGN_DETAIL_DATA = {**CAMPAIGN_DATA, "html_content": "

Hi

"} + +# Minimal payload — only the 6 OpenAPI-required fields; every nullable key +# absent. Exercises `_parse_campaign`'s `.get()` defaults so a refactor that +# regresses to `d["subject"]` would fail loudly here. +MINIMAL_CAMPAIGN_DATA = { + "id": "camp_min", + "name": "Draft", + "status": "draft", + "sent_count": 0, + "created_at": "2026-05-01T09:00:00+00:00", + "stats": STATS_DATA, +} + +EVENT_DATA = { + "event_id": "92356829", + "event_type": "open", + "email": "jane@example.com", + "timestamp": "2026-05-01T12:30:00+00:00", + "bounce_class": None, + "reason": None, + "target_link_url": None, + "user_agent": "Mozilla/5.0", +} + +PAGINATION = {"total": 1, "per_page": 20, "current_page": 1, "last_page": 1} + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestList: + def test_list(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"campaigns": [CAMPAIGN_DATA], "pagination": PAGINATION} + } + page = campaigns.list() + assert isinstance(page, CampaignPage) + assert len(page.campaigns) == 1 + assert page.campaigns[0].name == "Spring Sale" + assert isinstance(page.campaigns[0].stats, CampaignStats) + assert page.campaigns[0].stats.unique_opens == 45 + # list responses do not include the rendered body + assert page.campaigns[0].html_content is None + assert page.total == 1 + assert page.per_page == 20 + mock_client.get.assert_called_once_with("/campaigns", params={}) + + def test_list_forwards_filters(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": {"campaigns": [], "pagination": PAGINATION}} + campaigns.list(page=2, per_page=50, status="sent") + params = mock_client.get.call_args.kwargs["params"] + assert params == {"page": 2, "per_page": 50, "status": "sent"} + + def test_list_handles_minimal_payload( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + # Every nullable key absent — exercises the `.get()` defaults in the parser + # so a refactor to `d["subject"]` would crash with KeyError. + mock_client.get.return_value = { + "data": {"campaigns": [MINIMAL_CAMPAIGN_DATA], "pagination": PAGINATION} + } + page = campaigns.list() + c = page.campaigns[0] + assert c.id == "camp_min" + assert c.subject is None + assert c.from_email is None + assert c.from_name is None + assert c.reply_to is None + assert c.scheduled_at is None + assert c.total_recipients is None + assert c.sent_at is None + assert c.html_content is None + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +class TestGet: + def test_get(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": CAMPAIGN_DETAIL_DATA} + result = campaigns.get("camp_1") + assert isinstance(result, Campaign) + assert result.id == "camp_1" + assert result.html_content == "

Hi

" + assert result.stats.clicks == 20 + mock_client.get.assert_called_once_with("/campaigns/camp_1") + + +# --------------------------------------------------------------------------- +# list_events +# --------------------------------------------------------------------------- + + +class TestListEvents: + def test_list_events(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": {"events": [EVENT_DATA], "next_cursor": "abc123"}} + page = campaigns.list_events("camp_1") + assert isinstance(page, CampaignEventPage) + assert len(page.events) == 1 + assert page.events[0].event_type == "open" + assert page.events[0].user_agent == "Mozilla/5.0" + assert page.next_cursor == "abc123" + mock_client.get.assert_called_once_with("/campaigns/camp_1/events", params={}) + + def test_list_events_forwards_filters( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + mock_client.get.return_value = {"data": {"events": [], "next_cursor": None}} + campaigns.list_events( + "camp_1", + event_type="click", + email="jane@example.com", + start_date="2026-05-01T00:00:00Z", + end_date="2026-05-31T00:00:00Z", + limit=50, + cursor="cur_1", + ) + params = mock_client.get.call_args.kwargs["params"] + assert params == { + "event_type": "click", + "email": "jane@example.com", + "start_date": "2026-05-01T00:00:00Z", + "end_date": "2026-05-31T00:00:00Z", + "limit": 50, + "cursor": "cur_1", + } + + def test_list_events_null_cursor(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": {"events": [], "next_cursor": None}} + page = campaigns.list_events("camp_1") + assert page.next_cursor is None + + +# --------------------------------------------------------------------------- +# send / schedule / unschedule +# --------------------------------------------------------------------------- + + +class TestSend: + def test_send_returns_campaign(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"message": "Sending.", "data": CAMPAIGN_DATA} + result = campaigns.send("camp_1") + assert isinstance(result, Campaign) + assert result.id == "camp_1" + mock_client.post.assert_called_once_with("/campaigns/camp_1/send") + + def test_send_without_data_returns_none( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"message": "Sending."} + assert campaigns.send("camp_1") is None + + +class TestSchedule: + def test_schedule_returns_campaign(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + scheduled = {**CAMPAIGN_DATA, "status": "scheduled"} + mock_client.post.return_value = {"message": "Scheduled.", "data": scheduled} + result = campaigns.schedule("camp_1", scheduled_at="2026-06-01T09:00:00+00:00") + assert isinstance(result, Campaign) + assert result.status == "scheduled" + mock_client.post.assert_called_once_with( + "/campaigns/camp_1/schedule", + json={"scheduled_at": "2026-06-01T09:00:00+00:00"}, + ) + + def test_schedule_accepts_datetime(self, campaigns: Campaigns, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"message": "Scheduled.", "data": CAMPAIGN_DATA} + dt = datetime(2026, 6, 1, 9, 0, 0, tzinfo=timezone.utc) + campaigns.schedule("camp_1", scheduled_at=dt) + mock_client.post.assert_called_once_with( + "/campaigns/camp_1/schedule", + json={"scheduled_at": dt.isoformat()}, + ) + + def test_schedule_without_data_returns_none( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"message": "Scheduled."} + assert campaigns.schedule("camp_1", scheduled_at="2026-06-01T09:00:00+00:00") is None + + +class TestUnschedule: + def test_unschedule_returns_campaign( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + drafted = {**CAMPAIGN_DATA, "status": "draft"} + mock_client.post.return_value = {"message": "Unscheduled.", "data": drafted} + result = campaigns.unschedule("camp_1") + assert isinstance(result, Campaign) + assert result.status == "draft" + mock_client.post.assert_called_once_with("/campaigns/camp_1/unschedule") + + def test_unschedule_without_data_returns_none( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"message": "Unscheduled."} + assert campaigns.unschedule("camp_1") is None