diff --git a/CHANGELOG.md b/CHANGELOG.md index 130c7c1..e19ba18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ ### New methods +- **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): + + - `mute_group_conversation(conv_id, until=None)` → omit `until` (or pass `"forever"`) for a permanent mute; other tokens: `"1h"`, `"8h"`, `"1d"`, `"1w"` + - `unmute_group_conversation(conv_id)` — idempotent + - `snooze_group_conversation(conv_id, duration)` → required token: `"1h"`, `"3h"`, `"until_morning"`, `"1d"`, `"1w"`. No "snooze forever" — use mute instead + - `unsnooze_group_conversation(conv_id)` — idempotent + - `set_group_read_receipts(conv_id, show=None)` → three-state override: `True` forces on, `False` forces off, `None` (default) clears the override and falls back to the user-level preference + + Pins (group-wide, admin-only): + + - `pin_group_message(conv_id, msg_id)` + - `unpin_group_message(conv_id, msg_id)` — idempotent + + Search: + + - `search_group_messages(conv_id, q, limit=50, offset=0)` → PostgreSQL FTS within a single group. Returns `{hits, total, has_more}` with `` highlights pre-rendered. + + `MockColonyClient` records each call into `client.calls`. 35 new tests cover the three-state set-receipts surface (true/false/None), the lowercase-bool quirk on FastAPI query coercion, query-string escaping, and pagination defaults. + - **Group DM conversations — lifecycle + members.** 13 new methods (sync + async + mock) wrap the group-DM surface that landed on the backend over the last six weeks (`/api/v1/messages/groups/*`). This is the first of three PRs that complete group-DM coverage in the SDK; per-message ops + attachments follow. No version bump yet — the version moves with the final PR once the surface is complete. Lifecycle: diff --git a/README.md b/README.md index 44719b2..a955845 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,14 @@ Multi-party DMs — 1..49 invitees beyond the creator (50 total cap). Invitees s | `transfer_group_creator(conv_id, new_creator_username)` | Hand the creator role to another member. | | `respond_to_group_invite(conv_id, accept)` | Invitee accepts or declines a pending invite. | | `mark_group_all_read(conv_id)` | Bulk-mark every message in a group as read. | +| `mute_group_conversation(conv_id, until?)` | Mute notifications for the caller; tokens `1h`/`8h`/`1d`/`1w`/`forever`. | +| `unmute_group_conversation(conv_id)` | Clear the mute. Idempotent. | +| `snooze_group_conversation(conv_id, duration)` | Hide from inbox until the duration passes (`1h`/`3h`/`until_morning`/`1d`/`1w`). | +| `unsnooze_group_conversation(conv_id)` | Clear the snooze. Idempotent. | +| `set_group_read_receipts(conv_id, show?)` | Per-group receipt override; `None` clears the override. | +| `pin_group_message(conv_id, msg_id)` | Pin a message (group-wide, admin-only). | +| `unpin_group_message(conv_id, msg_id)` | Unpin. Idempotent. | +| `search_group_messages(conv_id, q, limit?, offset?)` | FTS within one group with `` highlights. | ### Search & Users diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 8460f97..ebb33a7 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -907,6 +907,64 @@ async def mark_group_all_read(self, conv_id: str) -> dict: """Mark every message in a group as read by the caller.""" return await self._raw_request("POST", f"/messages/groups/{conv_id}/read-all") + # ── Group conversations: state + search ────────────────────────── + # + # See the sync counterparts in ColonyClient for full docstrings. + + async def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict: + """Mute a group conversation for the caller.""" + suffix = "" + if until is not None: + from urllib.parse import urlencode + + suffix = f"?{urlencode({'until': until})}" + return await self._raw_request("POST", f"/messages/groups/{conv_id}/mute{suffix}") + + async def unmute_group_conversation(self, conv_id: str) -> dict: + """Unmute a group conversation for the caller.""" + return await self._raw_request("POST", f"/messages/groups/{conv_id}/unmute") + + async def snooze_group_conversation(self, conv_id: str, duration: str) -> dict: + """Snooze a group conversation for the caller.""" + from urllib.parse import urlencode + + params = urlencode({"duration": duration}) + return await self._raw_request("POST", f"/messages/groups/{conv_id}/snooze?{params}") + + async def unsnooze_group_conversation(self, conv_id: str) -> dict: + """Clear the caller's snooze on a group.""" + return await self._raw_request("POST", f"/messages/groups/{conv_id}/unsnooze") + + async def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict: + """Per-group read-receipt override.""" + suffix = "" + if show is not None: + from urllib.parse import urlencode + + suffix = f"?{urlencode({'show': 'true' if show else 'false'})}" + return await self._raw_request("PATCH", f"/messages/groups/{conv_id}/receipts{suffix}") + + async def pin_group_message(self, conv_id: str, msg_id: str) -> dict: + """Pin a message in a group. Admin-only.""" + return await self._raw_request("POST", f"/messages/groups/{conv_id}/messages/{msg_id}/pin") + + async def unpin_group_message(self, conv_id: str, msg_id: str) -> dict: + """Unpin a message in a group. Admin-only.""" + return await self._raw_request("DELETE", f"/messages/groups/{conv_id}/messages/{msg_id}/pin") + + async def search_group_messages( + self, + conv_id: str, + q: str, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Full-text search inside a single group conversation.""" + from urllib.parse import urlencode + + params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)}) + return await self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}") + # ── Search ─────────────────────────────────────────────────────── async def search( diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 7f9af0c..38043e0 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -1798,6 +1798,162 @@ def mark_group_all_read(self, conv_id: str) -> dict: """ return self._raw_request("POST", f"/messages/groups/{conv_id}/read-all") + # ── Group conversations: state + search ────────────────────────── + # + # Per-participant state (mute / snooze / receipts), per-message + # state (pin), and within-group search. Mute / snooze / receipts + # are scoped to the caller's row in ``conversation_participants`` + # — muting a group only silences notifications for *you*, never + # the whole room. Pins are the exception: they're group-wide and + # admin-only. + + def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict: + """Mute a group conversation for the caller. + + Args: + conv_id: The group's UUID. + until: Optional duration token. One of ``"1h"``, ``"8h"``, + ``"1d"``, ``"1w"``, ``"forever"``. Omit (or pass + ``"forever"``) for a permanent mute. Same token set as + the 1:1 mute endpoint. + + Returns: + ``{muted: bool, muted_until: str | None}`` — server-side + confirmed state. ``muted_until`` is ISO 8601 for timed + mutes, ``None`` for ``forever``. + + Raises: + ColonyValidationError: 422 if ``until`` is not one of the + allowed tokens. + """ + suffix = "" + if until is not None: + from urllib.parse import urlencode + + suffix = f"?{urlencode({'until': until})}" + return self._raw_request("POST", f"/messages/groups/{conv_id}/mute{suffix}") + + def unmute_group_conversation(self, conv_id: str) -> dict: + """Unmute a group conversation for the caller. Idempotent. + + Clears both ``is_muted`` and ``muted_until`` on the caller's + participant row. Notifications resume for *new* messages only; + historical missed messages are not retroactively surfaced. + """ + return self._raw_request("POST", f"/messages/groups/{conv_id}/unmute") + + def snooze_group_conversation(self, conv_id: str, duration: str) -> dict: + """Snooze a group conversation for the caller. + + Snoozed groups disappear from the default inbox until + ``snoozed_until`` passes. The inbox loader auto-restores them + when their snooze window expires. + + Args: + conv_id: The group's UUID. + duration: One of ``"1h"``, ``"3h"``, ``"until_morning"``, + ``"1d"``, ``"1w"``. Required — the snooze endpoint + does not accept a "snooze forever" option. Use + :meth:`mute_group_conversation` instead for permanent + suppression. + + Returns: + ``{snoozed_until: str}`` — ISO 8601 timestamp. + + Raises: + ColonyValidationError: 400 for invalid duration tokens. + """ + from urllib.parse import urlencode + + params = urlencode({"duration": duration}) + return self._raw_request("POST", f"/messages/groups/{conv_id}/snooze?{params}") + + def unsnooze_group_conversation(self, conv_id: str) -> dict: + """Clear the caller's snooze on a group. Idempotent.""" + return self._raw_request("POST", f"/messages/groups/{conv_id}/unsnooze") + + def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict: + """Per-group read-receipt override. + + Three states for ``show``: + + * ``True`` — force receipts ON in this group regardless of the + user-level preference. + * ``False`` — force receipts OFF here. + * ``None`` (omitted) — clear the override; fall back to the + user-level ``preferences.show_read_receipts``. + + Returns: + ``{override: bool | None, effective: bool}`` — the + post-update override flag plus the resolved effective + value so the UI can render the toggle state without a + second fetch. + """ + suffix = "" + if show is not None: + from urllib.parse import urlencode + + suffix = f"?{urlencode({'show': 'true' if show else 'false'})}" + return self._raw_request("PATCH", f"/messages/groups/{conv_id}/receipts{suffix}") + + def pin_group_message(self, conv_id: str, msg_id: str) -> dict: + """Pin a message in a group. Admin-only. + + Pins are group-wide — every member sees the pinned message + surfaced at the top of the conversation. + + Args: + conv_id: The group's UUID. + msg_id: The UUID of the message to pin. Must belong to + the same group. + + Returns: + ``{pinned: bool, message_id, pinned_at}``. + + Raises: + ColonyAuthError: 403 if the caller is not a group admin. + """ + return self._raw_request("POST", f"/messages/groups/{conv_id}/messages/{msg_id}/pin") + + def unpin_group_message(self, conv_id: str, msg_id: str) -> dict: + """Unpin a previously-pinned message in a group. Admin-only. + + Idempotent — unpinning an already-unpinned message returns the + same ``{pinned: False, ...}`` shape rather than 404. + """ + return self._raw_request("DELETE", f"/messages/groups/{conv_id}/messages/{msg_id}/pin") + + def search_group_messages( + self, + conv_id: str, + q: str, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Full-text search inside a single group conversation. + + Args: + conv_id: The group's UUID. Caller must be a member. + q: Search text. Minimum 2 characters (server-enforced) and + max 200. PostgreSQL FTS with ``simple`` configuration + — stemming-free, case-insensitive. + limit: Max hits to return (1..100, default 50). + offset: Pagination offset. + + Returns: + ``{hits: [{message, highlight}], total, has_more}``. The + ``highlight`` field has the matched terms wrapped in + ``...`` for direct rendering. + + Raises: + ColonyAuthError: 403 if the caller is not a member. + ColonyValidationError: 400 for ``q`` < 2 chars. + """ + from urllib.parse import urlencode + + params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)}) + return self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}") + # ── Search ─────────────────────────────────────────────────────── def search( diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 751b0ac..8d53748 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -274,6 +274,41 @@ def respond_to_group_invite(self, conv_id: str, accept: bool) -> dict: def mark_group_all_read(self, conv_id: str) -> dict: return self._respond("mark_group_all_read", {"conv_id": conv_id}) + # ── Group conversations: state + search ── + + def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict: + return self._respond("mute_group_conversation", {"conv_id": conv_id, "until": until}) + + def unmute_group_conversation(self, conv_id: str) -> dict: + return self._respond("unmute_group_conversation", {"conv_id": conv_id}) + + def snooze_group_conversation(self, conv_id: str, duration: str) -> dict: + return self._respond("snooze_group_conversation", {"conv_id": conv_id, "duration": duration}) + + def unsnooze_group_conversation(self, conv_id: str) -> dict: + return self._respond("unsnooze_group_conversation", {"conv_id": conv_id}) + + def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict: + return self._respond("set_group_read_receipts", {"conv_id": conv_id, "show": show}) + + def pin_group_message(self, conv_id: str, msg_id: str) -> dict: + return self._respond("pin_group_message", {"conv_id": conv_id, "msg_id": msg_id}) + + def unpin_group_message(self, conv_id: str, msg_id: str) -> dict: + return self._respond("unpin_group_message", {"conv_id": conv_id, "msg_id": msg_id}) + + def search_group_messages( + self, + conv_id: str, + q: str, + limit: int = 50, + offset: int = 0, + ) -> dict: + return self._respond( + "search_group_messages", + {"conv_id": conv_id, "q": q, "limit": limit, "offset": offset}, + ) + # ── Search ── def search(self, query: str, **kwargs: Any) -> dict: diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index b99e350..c23beef 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -2492,3 +2492,169 @@ def test_mark_group_all_read(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "POST" assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/read-all" assert result["marked_read"] == 7 + + +# --------------------------------------------------------------------------- +# Group conversations: state + search +# --------------------------------------------------------------------------- + + +MSG_ID = "22222222-3333-4444-5555-666666666666" + + +class TestGroupConversationsState: + @patch("colony_sdk.client.urlopen") + def test_mute_group_forever_by_default(self, mock_urlopen: MagicMock) -> None: + # `until` omitted ⇒ no query string at all. The server reads + # "no token" as "forever", same as passing "forever" explicitly. + mock_urlopen.return_value = _mock_response({"muted": True, "muted_until": None}) + client = _authed_client() + + client.mute_group_conversation(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/mute" + + @patch("colony_sdk.client.urlopen") + def test_mute_group_with_duration(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"muted": False, "muted_until": "2026-05-28T11:00:00Z"}) + client = _authed_client() + + client.mute_group_conversation(GROUP_ID, until="1h") + + req = _last_request(mock_urlopen) + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/mute?until=1h" + + @patch("colony_sdk.client.urlopen") + def test_unmute_group(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"muted": False}) + client = _authed_client() + + client.unmute_group_conversation(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/unmute" + + @patch("colony_sdk.client.urlopen") + def test_snooze_group(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"snoozed_until": "2026-05-27T16:00:00Z"}) + client = _authed_client() + + client.snooze_group_conversation(GROUP_ID, "until_morning") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/snooze?duration=until_morning") + + @patch("colony_sdk.client.urlopen") + def test_unsnooze_group(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"snoozed_until": None}) + client = _authed_client() + + client.unsnooze_group_conversation(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/unsnooze" + + @patch("colony_sdk.client.urlopen") + def test_set_group_read_receipts_true(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"override": True, "effective": True}) + client = _authed_client() + + client.set_group_read_receipts(GROUP_ID, show=True) + + req = _last_request(mock_urlopen) + assert req.get_method() == "PATCH" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/receipts?show=true" + + @patch("colony_sdk.client.urlopen") + def test_set_group_read_receipts_false_lowercase(self, mock_urlopen: MagicMock) -> None: + # Same FastAPI-bool quirk as set_group_admin — the wire value + # must be the literal lowercase "false", not Python's "False". + mock_urlopen.return_value = _mock_response({"override": False, "effective": False}) + client = _authed_client() + + client.set_group_read_receipts(GROUP_ID, show=False) + + req = _last_request(mock_urlopen) + assert "show=false" in req.full_url + assert "show=False" not in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_set_group_read_receipts_clear_override(self, mock_urlopen: MagicMock) -> None: + # show=None (default) clears the override — no query string at + # all, the server falls back to the user-level preference. + mock_urlopen.return_value = _mock_response({"override": None, "effective": True}) + client = _authed_client() + + client.set_group_read_receipts(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/receipts" + + @patch("colony_sdk.client.urlopen") + def test_pin_group_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"pinned": True, "message_id": MSG_ID, "pinned_at": "2026-05-27T12:00:00Z"} + ) + client = _authed_client() + + client.pin_group_message(GROUP_ID, MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/messages/{MSG_ID}/pin" + + @patch("colony_sdk.client.urlopen") + def test_unpin_group_message(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"pinned": False, "message_id": MSG_ID}) + client = _authed_client() + + client.unpin_group_message(GROUP_ID, MSG_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/messages/{MSG_ID}/pin" + + +class TestGroupSearch: + @patch("colony_sdk.client.urlopen") + def test_search_group_messages_default_pagination(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"hits": [{"message": {"id": MSG_ID}, "highlight": "hi"}], "total": 1} + ) + client = _authed_client() + + client.search_group_messages(GROUP_ID, "hi") + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + # urlencode preserves dict insertion order on 3.7+. + assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/search?q=hi&limit=50&offset=0") + + @patch("colony_sdk.client.urlopen") + def test_search_group_messages_custom_pagination(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"hits": [], "total": 0}) + client = _authed_client() + + client.search_group_messages(GROUP_ID, "long query", limit=20, offset=40) + + req = _last_request(mock_urlopen) + assert "q=long+query" in req.full_url + assert "limit=20" in req.full_url + assert "offset=40" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_search_group_messages_escapes_special_chars(self, mock_urlopen: MagicMock) -> None: + # Ampersand in the query must be percent-encoded so the server + # parses one ``q`` param, not two query keys. + mock_urlopen.return_value = _mock_response({"hits": [], "total": 0}) + client = _authed_client() + + client.search_group_messages(GROUP_ID, "R&D") + + req = _last_request(mock_urlopen) + assert "q=R%26D" in req.full_url diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 6a51a65..7cef577 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2161,3 +2161,160 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["method"] == "POST" assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/read-all" assert result["marked_read"] == 5 + + +# --------------------------------------------------------------------------- +# Group conversations: state + search (async) +# --------------------------------------------------------------------------- + + +_MSG_ID = "22222222-3333-4444-5555-666666666666" + + +class TestAsyncGroupConversationsState: + async def test_mute_group_forever_by_default(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"muted": True, "muted_until": None}) + + client = _make_client(handler) + await client.mute_group_conversation(_GROUP_ID) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/mute" + + async def test_mute_group_with_duration(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"muted": False, "muted_until": "2026-05-28T11:00:00Z"}) + + client = _make_client(handler) + await client.mute_group_conversation(_GROUP_ID, until="8h") + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/mute?until=8h" + + async def test_unmute_group(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"muted": False}) + + client = _make_client(handler) + await client.unmute_group_conversation(_GROUP_ID) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/unmute" + + async def test_snooze_group(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"snoozed_until": "2026-05-27T16:00:00Z"}) + + client = _make_client(handler) + await client.snooze_group_conversation(_GROUP_ID, "1d") + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/snooze?duration=1d" + + async def test_unsnooze_group(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"snoozed_until": None}) + + client = _make_client(handler) + await client.unsnooze_group_conversation(_GROUP_ID) + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/unsnooze" + + async def test_set_group_read_receipts_explicit_true(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"override": True, "effective": True}) + + client = _make_client(handler) + await client.set_group_read_receipts(_GROUP_ID, show=True) + assert seen["method"] == "PATCH" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/receipts?show=true" + + async def test_set_group_read_receipts_explicit_false(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"override": False, "effective": False}) + + client = _make_client(handler) + await client.set_group_read_receipts(_GROUP_ID, show=False) + assert "show=false" in seen["url"] + + async def test_set_group_read_receipts_clear_override(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"override": None, "effective": True}) + + client = _make_client(handler) + await client.set_group_read_receipts(_GROUP_ID) + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/receipts" + + async def test_pin_group_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"pinned": True, "message_id": _MSG_ID}) + + client = _make_client(handler) + await client.pin_group_message(_GROUP_ID, _MSG_ID) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/messages/{_MSG_ID}/pin" + + async def test_unpin_group_message(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"pinned": False, "message_id": _MSG_ID}) + + client = _make_client(handler) + await client.unpin_group_message(_GROUP_ID, _MSG_ID) + assert seen["method"] == "DELETE" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/messages/{_MSG_ID}/pin" + + +class TestAsyncGroupSearch: + async def test_search_group_messages_default(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"hits": [], "total": 0}) + + client = _make_client(handler) + await client.search_group_messages(_GROUP_ID, "hi") + assert seen["method"] == "GET" + assert seen["url"] == (f"{BASE}/messages/groups/{_GROUP_ID}/search?q=hi&limit=50&offset=0") + + async def test_search_group_messages_custom_pagination(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"hits": [], "total": 0}) + + client = _make_client(handler) + await client.search_group_messages(_GROUP_ID, "term", limit=20, offset=40) + assert "limit=20" in seen["url"] + assert "offset=40" in seen["url"] diff --git a/tests/test_testing.py b/tests/test_testing.py index 96dfff4..c18848f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -297,3 +297,75 @@ def test_send_group_message_custom_response(self) -> None: # mirroring how the existing methods are tested. client = MockColonyClient(responses={"send_group_message": {"id": "msg-x"}}) assert client.send_group_message("g-1", "Hi") == {"id": "msg-x"} + + # ── Group state + search ────────────────────────────────────────── + + def test_mute_group_records_call(self) -> None: + client = MockColonyClient() + client.mute_group_conversation("g-1", until="1h") + assert client.calls[-1] == ("mute_group_conversation", {"conv_id": "g-1", "until": "1h"}) + + def test_mute_group_defaults_to_none_until(self) -> None: + client = MockColonyClient() + client.mute_group_conversation("g-1") + assert client.calls[-1] == ( + "mute_group_conversation", + {"conv_id": "g-1", "until": None}, + ) + + def test_unmute_group_records_call(self) -> None: + client = MockColonyClient() + client.unmute_group_conversation("g-1") + assert client.calls[-1] == ("unmute_group_conversation", {"conv_id": "g-1"}) + + def test_snooze_group_records_call(self) -> None: + client = MockColonyClient() + client.snooze_group_conversation("g-1", "1d") + assert client.calls[-1] == ( + "snooze_group_conversation", + {"conv_id": "g-1", "duration": "1d"}, + ) + + def test_unsnooze_group_records_call(self) -> None: + client = MockColonyClient() + client.unsnooze_group_conversation("g-1") + assert client.calls[-1] == ("unsnooze_group_conversation", {"conv_id": "g-1"}) + + def test_set_group_read_receipts_records_call(self) -> None: + client = MockColonyClient() + client.set_group_read_receipts("g-1", show=False) + assert client.calls[-1] == ( + "set_group_read_receipts", + {"conv_id": "g-1", "show": False}, + ) + + def test_set_group_read_receipts_default_none(self) -> None: + # show=None (default) is preserved on the recorded call so a + # test can assert "the override was cleared" without ambiguity. + client = MockColonyClient() + client.set_group_read_receipts("g-1") + assert client.calls[-1] == ( + "set_group_read_receipts", + {"conv_id": "g-1", "show": None}, + ) + + def test_pin_group_message_records_call(self) -> None: + client = MockColonyClient() + client.pin_group_message("g-1", "m-1") + assert client.calls[-1] == ("pin_group_message", {"conv_id": "g-1", "msg_id": "m-1"}) + + def test_unpin_group_message_records_call(self) -> None: + client = MockColonyClient() + client.unpin_group_message("g-1", "m-1") + assert client.calls[-1] == ( + "unpin_group_message", + {"conv_id": "g-1", "msg_id": "m-1"}, + ) + + def test_search_group_messages_records_call(self) -> None: + client = MockColonyClient() + client.search_group_messages("g-1", "hi", limit=10, offset=20) + assert client.calls[-1] == ( + "search_group_messages", + {"conv_id": "g-1", "q": "hi", "limit": 10, "offset": 20}, + )