diff --git a/cueapi/resources/messages.py b/cueapi/resources/messages.py index a7ea2e8..4d565f3 100644 --- a/cueapi/resources/messages.py +++ b/cueapi/resources/messages.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Optional +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional, Union if TYPE_CHECKING: from cueapi.client import CueAPI @@ -33,6 +34,7 @@ def send( reply_to_agent: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, idempotency_key: Optional[str] = None, + send_at: Optional[Union[str, datetime]] = None, ) -> dict: """Send a message. @@ -69,6 +71,11 @@ def send( metadata: Optional JSON metadata blob. idempotency_key: Optional ``Idempotency-Key`` header (≤255 chars). + send_at: Optional ISO 8601 timestamp (or ``datetime``) to + delay this message's delivery. If omitted, the message + is delivered immediately. Per-message scheduling landed + in cueapi #623 — server stores ``send_at`` on the + message row and the worker picks it up when due. Returns: Dict matching the server's ``MessageResponse`` shape. @@ -95,6 +102,10 @@ def send( payload["reply_to_agent"] = reply_to_agent if metadata is not None: payload["metadata"] = metadata + if send_at is not None: + payload["send_at"] = ( + send_at.isoformat() if isinstance(send_at, datetime) else send_at + ) headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} if idempotency_key is not None: diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py index d5c5ede..02f54c7 100644 --- a/tests/test_messages_resource.py +++ b/tests/test_messages_resource.py @@ -137,3 +137,64 @@ def test_ack(self): mock_client._post.assert_called_once_with( "/v1/messages/msg_x/ack", json={}, ) + + +class TestSendAt: + """Per-message scheduling via send_at (cueapi #623 parity port). + + Mock-based following the existing pattern in this file. Asserts on + the request body shape — that's the SDK contract; what the server + does with it (delay then deliver) is exercised by the server suite. + """ + + def test_send_with_send_at_iso_string(self): + """send_at as ISO 8601 string flows into the request body verbatim.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + send_at="2030-01-01T12:00:00Z", + ) + + mock_client._post.assert_called_once_with( + "/v1/messages", + json={ + "to": "recipient@y", + "body": "hi", + "send_at": "2030-01-01T12:00:00Z", + }, + headers={"X-Cueapi-From-Agent": "sender@x"}, + ) + + def test_send_with_send_at_datetime_auto_isoformats(self): + """send_at as datetime auto-serializes via .isoformat().""" + from datetime import datetime, timezone + + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + send_at=datetime(2030, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + + call = mock_client._post.call_args + assert call.kwargs["json"]["send_at"] == "2030-01-01T12:00:00+00:00" + + def test_send_without_send_at_omits_field(self): + """send_at unset → field NOT present in request body.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send(from_agent="sender@x", to="recipient@y", body="hi") + + call = mock_client._post.call_args + assert "send_at" not in call.kwargs["json"]