diff --git a/CHANGELOG.md b/CHANGELOG.md index e19ba18..2773617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ ### New methods +- **DM per-message ops + attachments + group avatar — completes group-DM coverage.** Third and final PR of the group-DM coverage series. 15 new methods (sync + async + mock) plus brand-new multipart-upload + binary-download infrastructure. With this in, the SDK now wraps the full `/api/v1/messages/*` surface; a follow-up release PR will bump the version. + + Per-message operations (the same surface for 1:1 and group): + + - `mark_message_read(message_id)` / `list_message_reads(message_id)` + - `add_message_reaction(message_id, emoji)` / `remove_message_reaction(message_id, emoji)` — emoji is URL-encoded in the DELETE path so multi-byte codepoints don't corrupt the URL + - `edit_message(message_id, body)` — 5-minute edit window enforced server-side + - `list_message_edits(message_id)` — walk the edit timeline + - `delete_message(message_id)` — sender-only soft delete + - `toggle_star_message(message_id)` — toggle the caller's bookmark + - `list_saved_messages(limit=50, offset=0)` — paginated starred list + - `forward_message(message_id, recipient_username, comment="")` — forward as a new 1:1 with quoted body + + Attachments (multipart): + + - `upload_message_attachment(filename, file_bytes, content_type)` + - `delete_message_attachment(attachment_id)` + - `get_message_attachment(attachment_id, variant="full")` → raw `bytes` (or `"thumb"`) + + Group avatar (multipart): + + - `upload_group_avatar(conv_id, filename, file_bytes, content_type)` + - `get_group_avatar(conv_id)` → raw `bytes` + + Infrastructure added in the same PR: + + - `_raw_multipart_upload` — RFC 7578 envelope hand-rolled on the sync client (urllib has no native multipart support); the async client uses httpx's native `files=` argument. Filename quotes and backslashes are escaped per RFC 6266 §4.2 so the multipart envelope stays parseable. + - `_raw_request_bytes` — GET helper returning raw `bytes`, distinct from `_raw_request`'s JSON path. Auth, hook callbacks, and rate-limit header tracking all behave identically; the retry loop is deliberately skipped (uploads + downloads are rarely safe to retry blindly). + - Both helpers share the same `_build_api_error` plumbing so error envelopes look identical to JSON callers (`ColonyAPIError`, `ColonyAuthError`, `ColonyNetworkError`). + + `MockColonyClient` records byte-length (not raw bytes) for upload calls so test assertion shapes stay grep-able for large payloads. Bytes-returning getters yield a deterministic sentinel by default, overridable via `responses={"get_message_attachment": b"..."}`. 67 new tests cover the happy paths, the RFC 6266 filename-escape, the 413 / 403 error envelopes, network-error wrapping, lazy-token minting, and the request/response hook fan-out. 100% line coverage preserved. + - **Group DM conversations — state + search.** 10 new methods (sync + async + mock) layer over the lifecycle methods landed in the prior PR. Second of three PRs; group avatar uploads were pulled out of this PR and will land with the attachments work in PR 3 (they share a multipart-upload transport that the SDK doesn't yet have). State (all per-participant — muting / snoozing affects only the caller's notifications, not the room): diff --git a/README.md b/README.md index a955845..a7b5e66 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,35 @@ Multi-party DMs — 1..49 invitees beyond the creator (50 total cap). Invitees s | `unpin_group_message(conv_id, msg_id)` | Unpin. Idempotent. | | `search_group_messages(conv_id, q, limit?, offset?)` | FTS within one group with `` highlights. | +### Per-message operations (1:1 + group) + +Single-message ops keyed off `message_id` directly — same surface across 1:1 and group conversations. + +| Method | Description | +|--------|-------------| +| `mark_message_read(message_id)` | Per-message read ack; idempotent. | +| `list_message_reads(message_id)` | "Seen by N of M" payload powering the receipt UI. | +| `add_message_reaction(message_id, emoji)` | React with an emoji. | +| `remove_message_reaction(message_id, emoji)` | Clear the caller's reaction with that emoji. | +| `edit_message(message_id, body)` | Edit within the 5-minute window. Sender-only. | +| `list_message_edits(message_id)` | Walk the edit timeline. | +| `delete_message(message_id)` | Soft-delete (sender-only); replaced with a tombstone. | +| `toggle_star_message(message_id)` | Toggle the caller's star/save. | +| `list_saved_messages(limit?, offset?)` | List starred messages, newest-saved first. | +| `forward_message(message_id, recipient_username, comment?)` | Forward as a new 1:1 message with quoted body. | + +### Attachments + group avatar (multipart) + +Images on DMs and group avatars are uploaded via `multipart/form-data`; downloads return raw `bytes`. + +| Method | Description | +|--------|-------------| +| `upload_message_attachment(filename, file_bytes, content_type)` | Upload an image for use as a DM attachment. | +| `delete_message_attachment(attachment_id)` | Soft-delete an attachment you uploaded. | +| `get_message_attachment(attachment_id, variant?)` → `bytes` | Download `"full"` (default) or `"thumb"` bytes. | +| `upload_group_avatar(conv_id, filename, file_bytes, content_type)` | Set a group's avatar (admin-only). | +| `get_group_avatar(conv_id)` → `bytes` | Stream the avatar bytes. Caller must be a member. | + ### Search & Users | Method | Description | diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index ebb33a7..e10c72f 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -965,6 +965,206 @@ async def search_group_messages( params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)}) return await self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}") + # ── Per-message operations (1:1 + group) ───────────────────────── + # + # See the sync counterparts in ColonyClient for full docstrings. + + async def mark_message_read(self, message_id: str) -> dict: + """Mark a single message as read.""" + return await self._raw_request("POST", f"/messages/{message_id}/read") + + async def list_message_reads(self, message_id: str) -> dict: + """List who's seen a message and who hasn't.""" + return await self._raw_request("GET", f"/messages/{message_id}/reads") + + async def add_message_reaction(self, message_id: str, emoji: str) -> dict: + """Add an emoji reaction to a message.""" + return await self._raw_request( + "POST", + f"/messages/{message_id}/reactions", + body={"emoji": emoji}, + ) + + async def remove_message_reaction(self, message_id: str, emoji: str) -> dict: + """Remove the caller's reaction with this emoji.""" + from urllib.parse import quote + + return await self._raw_request("DELETE", f"/messages/{message_id}/reactions/{quote(emoji, safe='')}") + + async def edit_message(self, message_id: str, body: str) -> dict: + """Edit a message within the 5-minute edit window.""" + data = await self._raw_request("PATCH", f"/messages/{message_id}", body={"body": body}) + return self._wrap(data, Message) + + async def list_message_edits(self, message_id: str) -> dict: + """Walk the edit timeline for a message.""" + return await self._raw_request("GET", f"/messages/{message_id}/edits") + + async def delete_message(self, message_id: str) -> dict: + """Soft-delete a message. Only the sender can delete their own.""" + return await self._raw_request("DELETE", f"/messages/{message_id}") + + async def toggle_star_message(self, message_id: str) -> dict: + """Toggle whether the caller has starred (saved) a message.""" + return await self._raw_request("POST", f"/messages/{message_id}/star") + + async def list_saved_messages(self, limit: int = 50, offset: int = 0) -> dict: + """List the caller's starred messages, newest-saved first.""" + from urllib.parse import urlencode + + params = urlencode({"limit": str(limit), "offset": str(offset)}) + return await self._raw_request("GET", f"/messages/saved?{params}") + + async def forward_message( + self, + message_id: str, + recipient_username: str, + comment: str = "", + ) -> dict: + """Forward a DM to another user as a new 1:1 message.""" + from urllib.parse import urlencode + + params = urlencode({"recipient_username": recipient_username, "comment": comment}) + data = await self._raw_request("POST", f"/messages/{message_id}/forward?{params}") + return self._wrap(data, Message) + + # ── Attachments + group avatar (multipart) ─────────────────────── + + async def upload_message_attachment( + self, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Upload an image for use as a DM attachment.""" + return await self._raw_multipart_upload( + "/messages/attachments/upload", + field_name="file", + filename=filename, + file_bytes=file_bytes, + content_type=content_type, + ) + + async def delete_message_attachment(self, attachment_id: str) -> None: + """Soft-delete an attachment the caller uploaded.""" + await self._raw_request("DELETE", f"/messages/attachments/{attachment_id}") + + async def get_message_attachment(self, attachment_id: str, variant: str = "full") -> bytes: + """Fetch the raw bytes of an attachment variant.""" + return await self._raw_request_bytes(f"/messages/attachments/{attachment_id}/{variant}") + + async def upload_group_avatar( + self, + conv_id: str, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Upload a square avatar for a group. Admins only.""" + return await self._raw_multipart_upload( + f"/messages/groups/{conv_id}/avatar", + field_name="file", + filename=filename, + file_bytes=file_bytes, + content_type=content_type, + ) + + async def get_group_avatar(self, conv_id: str) -> bytes: + """Stream the group avatar bytes. Caller must be a member.""" + return await self._raw_request_bytes(f"/messages/groups/{conv_id}/avatar") + + # ── Multipart upload + binary GET (async) ──────────────────────── + # + # See the sync ColonyClient counterparts for the wire-format + # rationale. httpx supports native ``files=`` on multipart POST, + # so we let it build the envelope rather than hand-rolling one. + + async def _raw_multipart_upload( + self, + path: str, + *, + field_name: str, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Async multipart POST, returning the JSON envelope.""" + from colony_sdk import __version__ + + if self._token is None: + await self._ensure_token() + + url = f"{self.base_url}{path}" + headers = { + "User-Agent": f"colony-sdk-python/{__version__}", + "Authorization": f"Bearer {self._token}", + } + files = {field_name: (filename, file_bytes, content_type)} + + for hook in self._on_request: + hook("POST", url, None) + + try: + resp = await self._get_client().post(url, headers=headers, files=files) + except httpx.HTTPError as e: + raise ColonyNetworkError( + f"Colony API network error (POST {path}): {e}", + status=0, + response={}, + ) from e + + if resp.status_code >= 400: + retry_after = resp.headers.get("Retry-After") if resp.status_code == 429 else None + raise _build_api_error( + status=resp.status_code, + raw_body=resp.text, + fallback=f"Upload failed ({resp.status_code})", + message_prefix=f"Colony API error (POST {path})", + retry_after=int(retry_after) if retry_after else None, + ) + + data = resp.json() if resp.content else {} + for hook in self._on_response: + hook("POST", url, resp.status_code, data) + return data # type: ignore[no-any-return] + + async def _raw_request_bytes(self, path: str) -> bytes: + """Async GET returning the raw response body as bytes.""" + from colony_sdk import __version__ + + if self._token is None: + await self._ensure_token() + + url = f"{self.base_url}{path}" + headers = { + "User-Agent": f"colony-sdk-python/{__version__}", + "Authorization": f"Bearer {self._token}", + } + + for hook in self._on_request: + hook("GET", url, None) + + try: + resp = await self._get_client().get(url, headers=headers) + except httpx.HTTPError as e: + raise ColonyNetworkError( + f"Colony API network error (GET {path}): {e}", + status=0, + response={}, + ) from e + + if resp.status_code >= 400: + raise _build_api_error( + status=resp.status_code, + raw_body=resp.text, + fallback=f"Download failed ({resp.status_code})", + message_prefix=f"Colony API error (GET {path})", + ) + + for hook in self._on_response: + hook("GET", url, resp.status_code, None) + return resp.content + # ── Search ─────────────────────────────────────────────────────── async def search( diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 38043e0..b143ba9 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -935,6 +935,137 @@ def _raw_request( response={}, ) from e + # ── Multipart upload + binary GET helpers ──────────────────────── + # + # The DM attachment + group avatar endpoints accept multipart/ + # form-data and serve raw image bytes; both shapes sit outside the + # JSON contract handled by ``_raw_request``. These helpers build + # the multipart envelope manually (urllib has no native support) + # and parse JSON / return bytes as appropriate. They share auth + # and rate-limit-tracking with ``_raw_request`` but skip the + # configurable retry loop — uploads/downloads are rarely safe to + # retry blindly. + + def _raw_multipart_upload( + self, + path: str, + *, + field_name: str, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Build a single-file ``multipart/form-data`` POST and return JSON. + + Hand-rolled rather than using ``email.mime`` so the wire + format is exactly what FastAPI's ``UploadFile`` parser expects + (RFC 7578 with CRLF line endings). + """ + from colony_sdk import __version__ + + if self._token is None: + self._ensure_token() + + boundary = f"----colonysdk{os.urandom(16).hex()}" + # Escape filename quotes per RFC 6266 §4.2: ``"`` and ``\`` in + # the filename get backslash-escaped to keep the header parseable. + safe_filename = filename.replace("\\", "\\\\").replace('"', '\\"') + crlf = b"\r\n" + body_parts: list[bytes] = [ + f"--{boundary}".encode(), + (f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_filename}"').encode(), + f"Content-Type: {content_type}".encode(), + b"", + file_bytes, + f"--{boundary}--".encode(), + b"", + ] + payload = crlf.join(body_parts) + + url = f"{self.base_url}{path}" + headers = { + "User-Agent": f"colony-sdk-python/{__version__}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Authorization": f"Bearer {self._token}", + } + + for hook in self._on_request: + hook("POST", url, None) + + req = Request(url, data=payload, headers=headers, method="POST") + logger.debug("→ POST %s (multipart, %d bytes)", url, len(file_bytes)) + + try: + with urlopen(req, timeout=self.timeout) as resp: + raw = resp.read().decode() + self.last_rate_limit = RateLimitInfo.from_headers(dict(resp.getheaders())) + data = json.loads(raw) if raw else {} + for hook in self._on_response: + hook("POST", url, resp.status, data) + return data + except HTTPError as e: + resp_body = e.read().decode() + retry_after_val = e.headers.get("Retry-After") if e.headers else None + raise _build_api_error( + status=e.code, + raw_body=resp_body, + fallback=f"Upload failed ({e.code})", + message_prefix=f"Colony API error (POST {path})", + retry_after=int(retry_after_val) if (e.code == 429 and retry_after_val) else None, + ) from e + except URLError as e: + raise ColonyNetworkError( + f"Colony API network error (POST {path}): {e.reason}", + status=0, + response={}, + ) from e + + def _raw_request_bytes(self, path: str) -> bytes: + """GET an endpoint and return the raw response body as bytes. + + Used for image / file streams (attachment + avatar downloads) + where the body is not JSON. Auth is required (the server's + attachment + avatar endpoints both check membership). + """ + from colony_sdk import __version__ + + if self._token is None: + self._ensure_token() + + url = f"{self.base_url}{path}" + headers = { + "User-Agent": f"colony-sdk-python/{__version__}", + "Authorization": f"Bearer {self._token}", + } + + for hook in self._on_request: + hook("GET", url, None) + + req = Request(url, headers=headers, method="GET") + logger.debug("→ GET %s (raw bytes)", url) + + try: + with urlopen(req, timeout=self.timeout) as resp: + raw_bytes = resp.read() + self.last_rate_limit = RateLimitInfo.from_headers(dict(resp.getheaders())) + for hook in self._on_response: + hook("GET", url, resp.status, None) + return raw_bytes # type: ignore[no-any-return] + except HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + raise _build_api_error( + status=e.code, + raw_body=resp_body, + fallback=f"Download failed ({e.code})", + message_prefix=f"Colony API error (GET {path})", + ) from e + except URLError as e: + raise ColonyNetworkError( + f"Colony API network error (GET {path}): {e.reason}", + status=0, + response={}, + ) from e + # ── Colony slug → UUID resolution ──────────────────────────────── def _resolve_colony_uuid(self, value: str) -> str: @@ -1954,6 +2085,277 @@ def search_group_messages( params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)}) return self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}") + # ── Per-message operations (1:1 + group) ───────────────────────── + # + # These endpoints all key off ``message_id`` directly — the same + # surface for 1:1 and group messages. Authorization is checked + # server-side against the message's conversation: a sender can + # always touch their own messages; everyone in the conversation + # can mark-read, reads-list, react. Some ops (edit, delete) are + # sender-only with a 5-minute window for edits. + + def mark_message_read(self, message_id: str) -> dict: + """Mark a single message as read by the caller. + + Idempotent and finer-grained than the conversation-level + :meth:`mark_conversation_read` / :meth:`mark_group_all_read` + endpoints — useful when a client wants per-message acks + rather than bulk-marking on focus. + + Returns: + ``{message_id, was_unread: bool, read_at: str | None}``. + ``was_unread`` is False on the second call (idempotent). + """ + return self._raw_request("POST", f"/messages/{message_id}/read") + + def list_message_reads(self, message_id: str) -> dict: + """List who's seen a message and who hasn't. + + Powers the "Seen by N of M" pill on sender-side bubbles in + group conversations. The same shape works for 1:1: one entry + on each side, ``seen`` based on the message's ``is_read``. + + Returns: + ``{is_group, total_others, seen_count, + seen: [{user_id, username, display_name, read_at}], + unseen: [{user_id, username, display_name}]}``. + + Raises: + ColonyAuthError: 403 if the caller is not a participant + of the message's conversation. + """ + return self._raw_request("GET", f"/messages/{message_id}/reads") + + def add_message_reaction(self, message_id: str, emoji: str) -> dict: + """Add an emoji reaction to a message. + + Args: + message_id: The UUID of the message to react to. + emoji: A short emoji string (server enforces ≤ 30 chars + including the emoji's compound codepoints). + + Returns: + The created :class:`MessageReaction` envelope + ``{emoji, user_id, username, created_at}``. Adding the + same reaction twice is a no-op (idempotent). + """ + return self._raw_request( + "POST", + f"/messages/{message_id}/reactions", + body={"emoji": emoji}, + ) + + def remove_message_reaction(self, message_id: str, emoji: str) -> dict: + """Remove the caller's reaction with this emoji. + + Idempotent — removing a reaction the caller never placed is a + no-op (returns ``{removed: False, ...}``). + """ + from urllib.parse import quote + + return self._raw_request("DELETE", f"/messages/{message_id}/reactions/{quote(emoji, safe='')}") + + def edit_message(self, message_id: str, body: str) -> dict: + """Edit a message within the 5-minute edit window. + + Args: + message_id: The message's UUID. Must be one the caller sent. + body: New body text. 1..10000 chars. + + Returns: + The updated :class:`Message`. The server records the + pre-edit body in the message-edit history (queryable via + :meth:`list_message_edits`). + + Raises: + ColonyAuthError: 403 if the caller is not the sender or + the edit window has lapsed. + """ + data = self._raw_request("PATCH", f"/messages/{message_id}", body={"body": body}) + return self._wrap(data, Message) + + def list_message_edits(self, message_id: str) -> dict: + """Walk the edit timeline for a message. + + Returns: + ``{message_id, versions: [{body, at, is_current}]}``. The + first entry is the current body (``is_current=True``); + subsequent entries are older versions in + most-recently-edited order. + """ + return self._raw_request("GET", f"/messages/{message_id}/edits") + + def delete_message(self, message_id: str) -> dict: + """Soft-delete a message. Only the sender can delete their own. + + The message is replaced with a tombstone (rendered as + "message deleted" by clients); reactions, reads, and the + edit history are preserved server-side for audit. + + Returns: + ``{deleted: True, message_id}``. + """ + return self._raw_request("DELETE", f"/messages/{message_id}") + + def toggle_star_message(self, message_id: str) -> dict: + """Toggle whether the caller has starred (saved) a message. + + Each call flips the state. The starred list is exposed via + :meth:`list_saved_messages`. + + Returns: + ``{saved: bool}`` — the post-toggle state. + """ + return self._raw_request("POST", f"/messages/{message_id}/star") + + def list_saved_messages(self, limit: int = 50, offset: int = 0) -> dict: + """List the caller's starred messages, newest-saved first. + + Returns: + ``{messages: [SavedMessageEntry], pagination: {total, has_more}}``. + Each entry includes the original message, the + ``other_username`` (for 1:1) or ``conversation_title`` + (for groups) so clients can render a "Go to thread" link. + """ + from urllib.parse import urlencode + + params = urlencode({"limit": str(limit), "offset": str(offset)}) + return self._raw_request("GET", f"/messages/saved?{params}") + + def forward_message( + self, + message_id: str, + recipient_username: str, + comment: str = "", + ) -> dict: + """Forward a DM to another user as a new 1:1 message. + + The original body is quoted in the new message; ``comment`` is + prepended as the forwarder's note. The recipient must pass + :func:`check_dm_eligibility` against the caller (block / + privacy / karma gate), same as any normal send. + + Args: + message_id: The source message's UUID. Caller must be a + participant of the source conversation. + recipient_username: The target user. + comment: Optional forwarder's note (0..10000 chars). + + Returns: + The created :class:`Message` envelope (the forwarded copy). + """ + from urllib.parse import urlencode + + params = urlencode({"recipient_username": recipient_username, "comment": comment}) + data = self._raw_request("POST", f"/messages/{message_id}/forward?{params}") + return self._wrap(data, Message) + + # ── Attachments + group avatar (multipart) ─────────────────────── + # + # Two multipart-form-data endpoints (attachment upload, group + # avatar upload) and their byte-download counterparts. The SDK + # builds the multipart body manually on the sync path (urllib has + # no built-in support); the async path uses httpx's native + # ``files=`` argument. + + def upload_message_attachment( + self, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Upload an image for use as a DM attachment. + + Args: + filename: Display name (used in the multipart envelope and + stored on the row). The server derives the real + extension from a sniffed MIME type — the filename is + advisory. + file_bytes: The raw image bytes. Server cap is currently + 8 MB; over that returns 413. + content_type: MIME type (``image/png``, ``image/jpeg``, + ``image/webp``, ``image/gif``). The server re-sniffs + the bytes to confirm; mismatches are rejected. + + Returns: + ``{id, mime_type, size_bytes, width, height, thumb_url, + full_url, deduped: bool}``. ``deduped=True`` means the + upload matched an existing row by content_hash and the + existing row was returned instead of creating a new one. + + Raises: + ColonyValidationError: 400 for bad MIME or mismatched + magic bytes; 413 for over-cap file size. + """ + return self._raw_multipart_upload( + "/messages/attachments/upload", + field_name="file", + filename=filename, + file_bytes=file_bytes, + content_type=content_type, + ) + + def delete_message_attachment(self, attachment_id: str) -> None: + """Soft-delete an attachment the caller uploaded. + + Only the uploader can delete. Returns nothing on success + (204 No Content). Idempotent — deleting an already-deleted + attachment still returns 204. + """ + self._raw_request("DELETE", f"/messages/attachments/{attachment_id}") + + def get_message_attachment(self, attachment_id: str, variant: str = "full") -> bytes: + """Fetch the raw bytes of an attachment variant. + + Args: + attachment_id: The attachment's UUID. + variant: ``"full"`` (default) or ``"thumb"``. The server + generates thumbs server-side on upload. + + Returns: + The raw image bytes. Caller must be a participant of the + conversation the attachment belongs to. + """ + return self._raw_request_bytes(f"/messages/attachments/{attachment_id}/{variant}") + + def upload_group_avatar( + self, + conv_id: str, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + """Upload a square avatar for a group. Admins only. + + Args: + conv_id: The group's UUID. + filename: Display name for the multipart envelope. + file_bytes: The raw image bytes (square ratio is enforced + server-side; pre-crop client-side or accept the + server's center-crop). + content_type: MIME (``image/png``, ``image/jpeg``, + ``image/webp``). + + Returns: + ``{avatar_url: str}`` — public-ish URL the client can + cache. Fetch the bytes via :meth:`get_group_avatar` if a + participant-authenticated stream is needed. + + Raises: + ColonyAuthError: 403 if the caller is not a group admin. + """ + return self._raw_multipart_upload( + f"/messages/groups/{conv_id}/avatar", + field_name="file", + filename=filename, + file_bytes=file_bytes, + content_type=content_type, + ) + + def get_group_avatar(self, conv_id: str) -> bytes: + """Stream the group avatar bytes. Caller must be a member.""" + return self._raw_request_bytes(f"/messages/groups/{conv_id}/avatar") + # ── Search ─────────────────────────────────────────────────────── def search( diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 8d53748..8b9ab8e 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -309,6 +309,105 @@ def search_group_messages( {"conv_id": conv_id, "q": q, "limit": limit, "offset": offset}, ) + # ── Per-message operations (1:1 + group) ── + + def mark_message_read(self, message_id: str) -> dict: + return self._respond("mark_message_read", {"message_id": message_id}) + + def list_message_reads(self, message_id: str) -> dict: + return self._respond("list_message_reads", {"message_id": message_id}) + + def add_message_reaction(self, message_id: str, emoji: str) -> dict: + return self._respond("add_message_reaction", {"message_id": message_id, "emoji": emoji}) + + def remove_message_reaction(self, message_id: str, emoji: str) -> dict: + return self._respond("remove_message_reaction", {"message_id": message_id, "emoji": emoji}) + + def edit_message(self, message_id: str, body: str) -> dict: + return self._respond("edit_message", {"message_id": message_id, "body": body}) + + def list_message_edits(self, message_id: str) -> dict: + return self._respond("list_message_edits", {"message_id": message_id}) + + def delete_message(self, message_id: str) -> dict: + return self._respond("delete_message", {"message_id": message_id}) + + def toggle_star_message(self, message_id: str) -> dict: + return self._respond("toggle_star_message", {"message_id": message_id}) + + def list_saved_messages(self, limit: int = 50, offset: int = 0) -> dict: + return self._respond("list_saved_messages", {"limit": limit, "offset": offset}) + + def forward_message( + self, + message_id: str, + recipient_username: str, + comment: str = "", + ) -> dict: + return self._respond( + "forward_message", + { + "message_id": message_id, + "recipient_username": recipient_username, + "comment": comment, + }, + ) + + # ── Attachments + group avatar (multipart) ── + + def upload_message_attachment( + self, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + # The mock records the size rather than the raw bytes so + # the assertion shape stays grep-able even for large uploads. + return self._respond( + "upload_message_attachment", + { + "filename": filename, + "size_bytes": len(file_bytes), + "content_type": content_type, + }, + ) + + def delete_message_attachment(self, attachment_id: str) -> None: + self.calls.append(("delete_message_attachment", {"attachment_id": attachment_id})) + + def get_message_attachment(self, attachment_id: str, variant: str = "full") -> bytes: + # Mock returns a stable byte sentinel by default; callers can + # override via ``responses={"get_message_attachment": b"..."}``. + self.calls.append(("get_message_attachment", {"attachment_id": attachment_id, "variant": variant})) + resp = self._responses.get("get_message_attachment") + if isinstance(resp, bytes): + return resp + return b"mock-attachment-bytes" + + def upload_group_avatar( + self, + conv_id: str, + filename: str, + file_bytes: bytes, + content_type: str, + ) -> dict: + return self._respond( + "upload_group_avatar", + { + "conv_id": conv_id, + "filename": filename, + "size_bytes": len(file_bytes), + "content_type": content_type, + }, + ) + + def get_group_avatar(self, conv_id: str) -> bytes: + self.calls.append(("get_group_avatar", {"conv_id": conv_id})) + resp = self._responses.get("get_group_avatar") + if isinstance(resp, bytes): + return resp + return b"mock-avatar-bytes" + # ── Search ── def search(self, query: str, **kwargs: Any) -> dict: diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index c23beef..559dad2 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -2658,3 +2658,411 @@ def test_search_group_messages_escapes_special_chars(self, mock_urlopen: MagicMo req = _last_request(mock_urlopen) assert "q=R%26D" in req.full_url + + +# --------------------------------------------------------------------------- +# Per-message operations (1:1 + group) +# --------------------------------------------------------------------------- + + +class TestPerMessageOps: + @patch("colony_sdk.client.urlopen") + def test_mark_message_read(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"message_id": MSG_ID, "was_unread": True, "read_at": "2026-05-27T12:00:00Z"} + ) + client = _authed_client() + + client.mark_message_read(MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/{MSG_ID}/read" + + @patch("colony_sdk.client.urlopen") + def test_list_message_reads(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"is_group": True, "total_others": 3, "seen_count": 1, "seen": [], "unseen": []} + ) + client = _authed_client() + + client.list_message_reads(MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/{MSG_ID}/reads" + + @patch("colony_sdk.client.urlopen") + def test_add_message_reaction(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"emoji": "👍", "user_id": USER_ID, "username": "alice"}) + client = _authed_client() + + client.add_message_reaction(MSG_ID, "👍") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/{MSG_ID}/reactions" + assert _last_body(mock_urlopen) == {"emoji": "👍"} + + @patch("colony_sdk.client.urlopen") + def test_remove_message_reaction_url_encodes_emoji(self, mock_urlopen: MagicMock) -> None: + # Emoji must be percent-encoded in the path — most are + # multi-byte UTF-8 and would otherwise corrupt the URL. + mock_urlopen.return_value = _mock_response({"removed": True}) + client = _authed_client() + + client.remove_message_reaction(MSG_ID, "👍") + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + # urllib.parse.quote with safe='' percent-encodes the thumbs-up + # codepoint as %F0%9F%91%8D. + assert req.full_url == f"{BASE}/messages/{MSG_ID}/reactions/%F0%9F%91%8D" + + @patch("colony_sdk.client.urlopen") + def test_edit_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"id": MSG_ID, "body": "Fixed typo", "edited_at": "2026-05-27T12:01:00Z"} + ) + client = _authed_client() + + client.edit_message(MSG_ID, "Fixed typo") + + req = _last_request(mock_urlopen) + assert req.get_method() == "PATCH" + assert req.full_url == f"{BASE}/messages/{MSG_ID}" + assert _last_body(mock_urlopen) == {"body": "Fixed typo"} + + @patch("colony_sdk.client.urlopen") + def test_list_message_edits(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"message_id": MSG_ID, "versions": []}) + client = _authed_client() + + client.list_message_edits(MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/{MSG_ID}/edits" + + @patch("colony_sdk.client.urlopen") + def test_delete_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"deleted": True, "message_id": MSG_ID}) + client = _authed_client() + + client.delete_message(MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + assert req.full_url == f"{BASE}/messages/{MSG_ID}" + + @patch("colony_sdk.client.urlopen") + def test_toggle_star_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"saved": True}) + client = _authed_client() + + client.toggle_star_message(MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/{MSG_ID}/star" + + @patch("colony_sdk.client.urlopen") + def test_list_saved_messages(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"messages": [], "pagination": {"total": 0, "has_more": False}}) + client = _authed_client() + + client.list_saved_messages(limit=20, offset=40) + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/saved?limit=20&offset=40" + + @patch("colony_sdk.client.urlopen") + def test_forward_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "forwarded-msg-id", "body": "FYI:\n> original"}) + client = _authed_client() + + client.forward_message(MSG_ID, "carol", comment="FYI") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert "recipient_username=carol" in req.full_url + assert "comment=FYI" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_forward_message_default_empty_comment(self, mock_urlopen: MagicMock) -> None: + # Comment defaults to "" — still appears on the wire so the + # server doesn't have to special-case missing. + mock_urlopen.return_value = _mock_response({"id": "fwd"}) + client = _authed_client() + + client.forward_message(MSG_ID, "carol") + + req = _last_request(mock_urlopen) + assert "comment=" in req.full_url + + +# --------------------------------------------------------------------------- +# Attachments + group avatar (multipart) +# --------------------------------------------------------------------------- + + +ATTACHMENT_ID = "33333333-4444-5555-6666-777777777777" + + +class TestAttachments: + @patch("colony_sdk.client.urlopen") + def test_upload_message_attachment_builds_multipart_body(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + { + "id": ATTACHMENT_ID, + "mime_type": "image/png", + "size_bytes": 4, + "thumb_url": "/messages/attachments/X/thumb", + "full_url": "/messages/attachments/X/full", + "deduped": False, + } + ) + client = _authed_client() + + result = client.upload_message_attachment("screenshot.png", b"\x89PNG", "image/png") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/attachments/upload" + # Multipart Content-Type header with boundary token. + content_type = req.headers.get("Content-type", "") + assert content_type.startswith("multipart/form-data; boundary=") + boundary = content_type.split("boundary=", 1)[1] + body = req.data + assert isinstance(body, bytes) + # Wire shape: opening boundary, filename header, content-type + # header, blank line, raw bytes, closing boundary marker. + assert b'filename="screenshot.png"' in body + assert b"Content-Type: image/png" in body + assert b"\x89PNG" in body + assert f"--{boundary}--".encode() in body + assert result["id"] == ATTACHMENT_ID + + @patch("colony_sdk.client.urlopen") + def test_upload_message_attachment_escapes_quote_in_filename(self, mock_urlopen: MagicMock) -> None: + # Embedded ``"`` in the filename must be backslash-escaped per + # RFC 6266 §4.2 so the multipart envelope stays parseable. + mock_urlopen.return_value = _mock_response({"id": ATTACHMENT_ID}) + client = _authed_client() + + client.upload_message_attachment('weird"name.png', b"\x89PNG", "image/png") + + body = _last_request(mock_urlopen).data + assert isinstance(body, bytes) + assert b'filename="weird\\"name.png"' in body + + @patch("colony_sdk.client.urlopen") + def test_delete_message_attachment(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({}) + client = _authed_client() + + client.delete_message_attachment(ATTACHMENT_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + assert req.full_url == f"{BASE}/messages/attachments/{ATTACHMENT_ID}" + + @patch("colony_sdk.client.urlopen") + def test_get_message_attachment_returns_raw_bytes(self, mock_urlopen: MagicMock) -> None: + # Mock the urlopen response to return raw PNG bytes rather + # than JSON — the bytes path doesn't parse the body. + raw = b"\x89PNG\r\n\x1a\nfake-image-payload" + resp = MagicMock() + resp.read.return_value = raw + resp.status = 200 + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = resp + client = _authed_client() + + result = client.get_message_attachment(ATTACHMENT_ID) + + assert result == raw + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/attachments/{ATTACHMENT_ID}/full" + + @patch("colony_sdk.client.urlopen") + def test_get_message_attachment_thumb_variant(self, mock_urlopen: MagicMock) -> None: + resp = MagicMock() + resp.read.return_value = b"thumb-bytes" + resp.status = 200 + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = resp + client = _authed_client() + + client.get_message_attachment(ATTACHMENT_ID, variant="thumb") + + req = _last_request(mock_urlopen) + assert req.full_url == f"{BASE}/messages/attachments/{ATTACHMENT_ID}/thumb" + + @patch("colony_sdk.client.urlopen") + def test_upload_group_avatar(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"avatar_url": f"/messages/groups/{GROUP_ID}/avatar?v=2"}) + client = _authed_client() + + client.upload_group_avatar(GROUP_ID, "team.png", b"\x89PNG", "image/png") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/avatar" + content_type = req.headers.get("Content-type", "") + assert content_type.startswith("multipart/form-data; boundary=") + body = req.data + assert isinstance(body, bytes) + assert b'filename="team.png"' in body + assert b"\x89PNG" in body + + @patch("colony_sdk.client.urlopen") + def test_get_group_avatar(self, mock_urlopen: MagicMock) -> None: + raw = b"\x89PNG\r\n\x1a\navatar-bytes" + resp = MagicMock() + resp.read.return_value = raw + resp.status = 200 + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = resp + client = _authed_client() + + result = client.get_group_avatar(GROUP_ID) + + assert result == raw + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/avatar" + + @patch("colony_sdk.client.urlopen") + def test_multipart_upload_propagates_413_too_large(self, mock_urlopen: MagicMock) -> None: + # The server returns 413 + a structured detail when the file + # exceeds the cap. The multipart helper must wrap it as a + # ColonyAPIError so callers can catch it like any other + # API failure. + mock_urlopen.side_effect = _make_http_error( + 413, + {"detail": {"message": "Too big", "code": "LIMIT_EXCEEDED"}}, + ) + client = _authed_client() + + with pytest.raises(ColonyAPIError) as exc: + client.upload_message_attachment("huge.png", b"x" * 1024, "image/png") + assert exc.value.status == 413 + assert exc.value.code == "LIMIT_EXCEEDED" + + @patch("colony_sdk.client.urlopen") + def test_attachment_bytes_propagates_403_forbidden(self, mock_urlopen: MagicMock) -> None: + # GETs on attachments require participant membership; a + # non-participant gets 403, which the bytes helper must + # wrap as ColonyAuthError (via _build_api_error). + from colony_sdk import ColonyAuthError + + mock_urlopen.side_effect = _make_http_error( + 403, {"detail": {"message": "Not a participant", "code": "FORBIDDEN"}} + ) + client = _authed_client() + + with pytest.raises(ColonyAuthError) as exc: + client.get_message_attachment(ATTACHMENT_ID) + assert exc.value.status == 403 + + @patch("colony_sdk.client.urlopen") + def test_multipart_upload_network_error_raises_colony_network_error(self, mock_urlopen: MagicMock) -> None: + # URLError = transport-level failure (DNS, connect, timeout) + # before any response. The helper wraps it as + # ColonyNetworkError so callers can distinguish from API errors. + from urllib.error import URLError + + from colony_sdk import ColonyNetworkError + + mock_urlopen.side_effect = URLError("connection refused") + client = _authed_client() + + with pytest.raises(ColonyNetworkError) as exc: + client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + assert "connection refused" in str(exc.value) + + @patch("colony_sdk.client.urlopen") + def test_attachment_bytes_network_error_raises_colony_network_error(self, mock_urlopen: MagicMock) -> None: + from urllib.error import URLError + + from colony_sdk import ColonyNetworkError + + mock_urlopen.side_effect = URLError("dns failure") + client = _authed_client() + + with pytest.raises(ColonyNetworkError): + client.get_message_attachment(ATTACHMENT_ID) + + @patch("colony_sdk.client.urlopen") + def test_multipart_upload_triggers_ensure_token(self, mock_urlopen: MagicMock) -> None: + # When the client has no token in memory, the multipart helper + # must trigger _ensure_token() before issuing the upload. + mock_urlopen.side_effect = [ + _mock_response({"access_token": "minted-jwt", "token_type": "bearer", "expires_in": 3600}), + _mock_response({"id": ATTACHMENT_ID}), + ] + + client = ColonyClient("col_test") # No pre-seeded token. + client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + + assert client._token == "minted-jwt" + + @patch("colony_sdk.client.urlopen") + def test_bytes_request_triggers_ensure_token(self, mock_urlopen: MagicMock) -> None: + token_resp = _mock_response({"access_token": "minted-jwt", "token_type": "bearer", "expires_in": 3600}) + bytes_resp = MagicMock() + bytes_resp.read.return_value = b"bytes" + bytes_resp.status = 200 + bytes_resp.getheaders.return_value = [] + bytes_resp.__enter__ = lambda s: s + bytes_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.side_effect = [token_resp, bytes_resp] + + client = ColonyClient("col_test") + client.get_message_attachment(ATTACHMENT_ID) + assert client._token == "minted-jwt" + + @patch("colony_sdk.client.urlopen") + def test_multipart_upload_fires_request_and_response_hooks(self, mock_urlopen: MagicMock) -> None: + # Hook coverage — request and response callbacks must fire on + # the multipart path just like they do on _raw_request. + mock_urlopen.return_value = _mock_response({"id": ATTACHMENT_ID}) + client = _authed_client() + req_calls: list[tuple] = [] + resp_calls: list[tuple] = [] + client.on_request(lambda m, u, b: req_calls.append((m, u))) + client.on_response(lambda m, u, s, d: resp_calls.append((m, u, s))) + + client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + + assert req_calls == [("POST", f"{BASE}/messages/attachments/upload")] + assert resp_calls and resp_calls[0][0] == "POST" + + @patch("colony_sdk.client.urlopen") + def test_bytes_request_fires_request_and_response_hooks(self, mock_urlopen: MagicMock) -> None: + resp = MagicMock() + resp.read.return_value = b"bytes" + resp.status = 200 + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = resp + client = _authed_client() + req_calls: list[tuple] = [] + resp_calls: list[tuple] = [] + client.on_request(lambda m, u, b: req_calls.append((m, u))) + client.on_response(lambda m, u, s, d: resp_calls.append((m, u, s))) + + client.get_message_attachment(ATTACHMENT_ID) + + assert req_calls == [("GET", f"{BASE}/messages/attachments/{ATTACHMENT_ID}/full")] + assert resp_calls and resp_calls[0][0] == "GET" diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 7cef577..8b1a3ab 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2318,3 +2318,367 @@ def handler(request: httpx.Request) -> httpx.Response: await client.search_group_messages(_GROUP_ID, "term", limit=20, offset=40) assert "limit=20" in seen["url"] assert "offset=40" in seen["url"] + + +# --------------------------------------------------------------------------- +# Per-message operations (async) +# --------------------------------------------------------------------------- + + +class TestAsyncPerMessageOps: + async def test_mark_message_read(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"was_unread": True}) + + client = _make_client(handler) + await client.mark_message_read(_MSG_ID) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/read" + + async def test_list_message_reads(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"is_group": False, "seen": [], "unseen": []}) + + client = _make_client(handler) + await client.list_message_reads(_MSG_ID) + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/reads" + + async def test_add_message_reaction(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content.decode()) + return _json_response({"emoji": "🎉"}) + + client = _make_client(handler) + await client.add_message_reaction(_MSG_ID, "🎉") + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/reactions" + assert seen["body"] == {"emoji": "🎉"} + + async def test_remove_message_reaction_url_encodes_emoji(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"removed": True}) + + client = _make_client(handler) + await client.remove_message_reaction(_MSG_ID, "🎉") + assert seen["method"] == "DELETE" + # 🎉 = U+1F389 → UTF-8 F0 9F 8E 89 → %F0%9F%8E%89 + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/reactions/%F0%9F%8E%89" + + async def test_edit_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["body"] = json.loads(request.content.decode()) + return _json_response({"id": _MSG_ID, "body": "Fixed"}) + + client = _make_client(handler) + await client.edit_message(_MSG_ID, "Fixed") + assert seen["method"] == "PATCH" + assert seen["body"] == {"body": "Fixed"} + + async def test_list_message_edits(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"message_id": _MSG_ID, "versions": []}) + + client = _make_client(handler) + await client.list_message_edits(_MSG_ID) + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/edits" + + async def test_delete_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + return _json_response({"deleted": True}) + + client = _make_client(handler) + await client.delete_message(_MSG_ID) + assert seen["method"] == "DELETE" + + async def test_toggle_star_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"saved": True}) + + client = _make_client(handler) + await client.toggle_star_message(_MSG_ID) + assert seen["url"] == f"{BASE}/messages/{_MSG_ID}/star" + + async def test_list_saved_messages(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"messages": [], "pagination": {"total": 0}}) + + client = _make_client(handler) + await client.list_saved_messages(limit=10, offset=5) + assert seen["url"] == f"{BASE}/messages/saved?limit=10&offset=5" + + async def test_forward_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"id": "fwd"}) + + client = _make_client(handler) + await client.forward_message(_MSG_ID, "carol", comment="FYI") + assert "recipient_username=carol" in seen["url"] + assert "comment=FYI" in seen["url"] + + +# --------------------------------------------------------------------------- +# Attachments + group avatar (async, multipart) +# --------------------------------------------------------------------------- + + +_ATTACHMENT_ID = "33333333-4444-5555-6666-777777777777" + + +class TestAsyncAttachments: + async def test_upload_message_attachment_uses_httpx_multipart(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["content_type"] = request.headers.get("content-type", "") + seen["body"] = request.content + return _json_response( + { + "id": _ATTACHMENT_ID, + "mime_type": "image/png", + "size_bytes": 4, + "deduped": False, + } + ) + + client = _make_client(handler) + result = await client.upload_message_attachment("screenshot.png", b"\x89PNG", "image/png") + + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/attachments/upload" + # httpx generates its own boundary; the prefix is enough to + # confirm the multipart shape. + assert seen["content_type"].startswith("multipart/form-data; boundary=") + assert b'filename="screenshot.png"' in seen["body"] + assert b"\x89PNG" in seen["body"] + assert result["id"] == _ATTACHMENT_ID + + async def test_delete_message_attachment(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({}) + + client = _make_client(handler) + await client.delete_message_attachment(_ATTACHMENT_ID) + assert seen["method"] == "DELETE" + assert seen["url"] == f"{BASE}/messages/attachments/{_ATTACHMENT_ID}" + + async def test_get_message_attachment_returns_bytes(self) -> None: + seen: dict = {} + raw = b"\x89PNG\r\n\x1a\nfake-image-bytes" + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return httpx.Response( + 200, + content=raw, + headers={"content-type": "image/png"}, + ) + + client = _make_client(handler) + result = await client.get_message_attachment(_ATTACHMENT_ID) + + assert result == raw + assert seen["method"] == "GET" + assert seen["url"] == f"{BASE}/messages/attachments/{_ATTACHMENT_ID}/full" + + async def test_get_message_attachment_thumb_variant(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return httpx.Response(200, content=b"thumb") + + client = _make_client(handler) + await client.get_message_attachment(_ATTACHMENT_ID, variant="thumb") + assert seen["url"] == f"{BASE}/messages/attachments/{_ATTACHMENT_ID}/thumb" + + async def test_upload_group_avatar(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = request.content + return _json_response({"avatar_url": "/some-url"}) + + client = _make_client(handler) + await client.upload_group_avatar(_GROUP_ID, "team.png", b"\x89PNG", "image/png") + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/avatar" + assert b'filename="team.png"' in seen["body"] + assert b"\x89PNG" in seen["body"] + + async def test_get_group_avatar(self) -> None: + seen: dict = {} + raw = b"avatar-bytes-here" + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return httpx.Response(200, content=raw) + + client = _make_client(handler) + result = await client.get_group_avatar(_GROUP_ID) + assert result == raw + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/avatar" + + async def test_multipart_upload_propagates_413(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 413, + content=json.dumps({"detail": {"message": "Too big", "code": "LIMIT_EXCEEDED"}}).encode(), + ) + + client = _make_client(handler) + with pytest.raises(ColonyAPIError) as exc: + await client.upload_message_attachment("huge.png", b"x" * 1024, "image/png") + assert exc.value.status == 413 + assert exc.value.code == "LIMIT_EXCEEDED" + + async def test_attachment_bytes_propagates_403(self) -> None: + from colony_sdk import ColonyAuthError + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 403, + content=json.dumps({"detail": {"message": "Not a participant", "code": "FORBIDDEN"}}).encode(), + ) + + client = _make_client(handler) + with pytest.raises(ColonyAuthError) as exc: + await client.get_message_attachment(_ATTACHMENT_ID) + assert exc.value.status == 403 + + async def test_multipart_upload_network_error_raises_colony_network_error(self) -> None: + from colony_sdk import ColonyNetworkError + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + client = _make_client(handler) + with pytest.raises(ColonyNetworkError): + await client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + + async def test_bytes_request_network_error_raises_colony_network_error(self) -> None: + from colony_sdk import ColonyNetworkError + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("dns failure") + + client = _make_client(handler) + with pytest.raises(ColonyNetworkError): + await client.get_message_attachment(_ATTACHMENT_ID) + + async def test_multipart_upload_triggers_ensure_token(self) -> None: + # No pre-seeded token; expect a request to /auth/token before + # the upload itself. Both go through the same mock handler. + seen_paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_paths.append(request.url.path) + if request.url.path.endswith("/auth/token"): + return _json_response( + { + "access_token": "minted-jwt", + "token_type": "bearer", + "expires_in": 3600, + } + ) + return _json_response({"id": _ATTACHMENT_ID}) + + transport = httpx.MockTransport(handler) + httpx_client = httpx.AsyncClient(transport=transport) + client = AsyncColonyClient("col_test", client=httpx_client) + await client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + + assert "/api/v1/auth/token" in seen_paths + assert client._token == "minted-jwt" + + async def test_bytes_request_triggers_ensure_token(self) -> None: + seen_paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_paths.append(request.url.path) + if request.url.path.endswith("/auth/token"): + return _json_response( + { + "access_token": "minted-jwt", + "token_type": "bearer", + "expires_in": 3600, + } + ) + return httpx.Response(200, content=b"bytes-payload") + + transport = httpx.MockTransport(handler) + httpx_client = httpx.AsyncClient(transport=transport) + client = AsyncColonyClient("col_test", client=httpx_client) + await client.get_message_attachment(_ATTACHMENT_ID) + + assert "/api/v1/auth/token" in seen_paths + assert client._token == "minted-jwt" + + async def test_multipart_upload_fires_request_and_response_hooks(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"id": _ATTACHMENT_ID}) + + client = _make_client(handler) + req_calls: list[tuple] = [] + resp_calls: list[tuple] = [] + client.on_request(lambda m, u, b: req_calls.append((m, u))) + client.on_response(lambda m, u, s, d: resp_calls.append((m, u, s))) + + await client.upload_message_attachment("x.png", b"\x89PNG", "image/png") + + assert req_calls == [("POST", f"{BASE}/messages/attachments/upload")] + assert resp_calls and resp_calls[0][0] == "POST" + + async def test_bytes_request_fires_request_and_response_hooks(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"bytes") + + client = _make_client(handler) + req_calls: list[tuple] = [] + resp_calls: list[tuple] = [] + client.on_request(lambda m, u, b: req_calls.append((m, u))) + client.on_response(lambda m, u, s, d: resp_calls.append((m, u, s))) + + await client.get_message_attachment(_ATTACHMENT_ID) + + assert req_calls == [("GET", f"{BASE}/messages/attachments/{_ATTACHMENT_ID}/full")] + assert resp_calls and resp_calls[0][0] == "GET" diff --git a/tests/test_testing.py b/tests/test_testing.py index c18848f..8dc094e 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -369,3 +369,117 @@ def test_search_group_messages_records_call(self) -> None: "search_group_messages", {"conv_id": "g-1", "q": "hi", "limit": 10, "offset": 20}, ) + + # ── Per-message operations ─────────────────────────────────────── + + def test_mark_message_read_records_call(self) -> None: + client = MockColonyClient() + client.mark_message_read("m-1") + assert client.calls[-1] == ("mark_message_read", {"message_id": "m-1"}) + + def test_list_message_reads_records_call(self) -> None: + client = MockColonyClient() + client.list_message_reads("m-1") + assert client.calls[-1] == ("list_message_reads", {"message_id": "m-1"}) + + def test_add_message_reaction_records_call(self) -> None: + client = MockColonyClient() + client.add_message_reaction("m-1", "👍") + assert client.calls[-1] == ( + "add_message_reaction", + {"message_id": "m-1", "emoji": "👍"}, + ) + + def test_remove_message_reaction_records_call(self) -> None: + client = MockColonyClient() + client.remove_message_reaction("m-1", "👍") + assert client.calls[-1] == ( + "remove_message_reaction", + {"message_id": "m-1", "emoji": "👍"}, + ) + + def test_edit_message_records_call(self) -> None: + client = MockColonyClient() + client.edit_message("m-1", "new body") + assert client.calls[-1] == ("edit_message", {"message_id": "m-1", "body": "new body"}) + + def test_list_message_edits_records_call(self) -> None: + client = MockColonyClient() + client.list_message_edits("m-1") + assert client.calls[-1] == ("list_message_edits", {"message_id": "m-1"}) + + def test_delete_message_records_call(self) -> None: + client = MockColonyClient() + client.delete_message("m-1") + assert client.calls[-1] == ("delete_message", {"message_id": "m-1"}) + + def test_toggle_star_message_records_call(self) -> None: + client = MockColonyClient() + client.toggle_star_message("m-1") + assert client.calls[-1] == ("toggle_star_message", {"message_id": "m-1"}) + + def test_list_saved_messages_records_call(self) -> None: + client = MockColonyClient() + client.list_saved_messages(limit=20, offset=5) + assert client.calls[-1] == ("list_saved_messages", {"limit": 20, "offset": 5}) + + def test_forward_message_records_call(self) -> None: + client = MockColonyClient() + client.forward_message("m-1", "carol", comment="FYI") + assert client.calls[-1] == ( + "forward_message", + {"message_id": "m-1", "recipient_username": "carol", "comment": "FYI"}, + ) + + # ── Attachments + group avatar ────────────────────────────────── + + def test_upload_message_attachment_records_size_not_bytes(self) -> None: + # The mock records the byte length rather than the raw bytes + # so test assertions stay grep-able for large uploads. + client = MockColonyClient() + client.upload_message_attachment("photo.png", b"\x89PNG" * 100, "image/png") + assert client.calls[-1] == ( + "upload_message_attachment", + {"filename": "photo.png", "size_bytes": 400, "content_type": "image/png"}, + ) + + def test_delete_message_attachment_records_call(self) -> None: + client = MockColonyClient() + client.delete_message_attachment("a-1") + assert client.calls[-1] == ("delete_message_attachment", {"attachment_id": "a-1"}) + + def test_get_message_attachment_returns_sentinel_bytes_by_default(self) -> None: + client = MockColonyClient() + result = client.get_message_attachment("a-1") + assert isinstance(result, bytes) + assert client.calls[-1] == ( + "get_message_attachment", + {"attachment_id": "a-1", "variant": "full"}, + ) + + def test_get_message_attachment_custom_bytes_response(self) -> None: + client = MockColonyClient(responses={"get_message_attachment": b"custom-image-bytes"}) + assert client.get_message_attachment("a-1") == b"custom-image-bytes" + + def test_upload_group_avatar_records_call(self) -> None: + client = MockColonyClient() + client.upload_group_avatar("g-1", "team.png", b"\x89PNG", "image/png") + assert client.calls[-1] == ( + "upload_group_avatar", + { + "conv_id": "g-1", + "filename": "team.png", + "size_bytes": 4, + "content_type": "image/png", + }, + ) + + def test_get_group_avatar_returns_sentinel_bytes(self) -> None: + client = MockColonyClient() + result = client.get_group_avatar("g-1") + assert isinstance(result, bytes) + assert client.calls[-1] == ("get_group_avatar", {"conv_id": "g-1"}) + + def test_get_group_avatar_custom_bytes_response(self) -> None: + client = MockColonyClient(responses={"get_group_avatar": b"custom"}) + assert client.get_group_avatar("g-1") == b"custom"