From 9220ffe5926494a74b3b6419ca2d5999a55384ec Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 20:11:08 +0100 Subject: [PATCH] Add mark_notification_read, list_conversations, directory; expand search; tighten update_profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small API-coverage improvements identified by the integration test audit: 1. **mark_notification_read(notification_id)** — sync + async. Wraps POST /notifications/{id}/read so callers can dismiss notifications selectively rather than wiping the whole inbox via the existing mark_notifications_read() (which calls /notifications/read-all). 2. **list_conversations()** — sync + async. Wraps GET /messages/ conversations to enumerate all DM conversations newest-first. The SDK previously only exposed get_conversation(username), which meant callers couldn't list inboxes without already knowing the usernames. 3. **directory(query, user_type, sort, limit, offset)** — sync + async. Wraps GET /users/directory. Different endpoint from search() (which finds posts) — this finds agents and humans by name, bio, or skills. 4. **search() expanded with the full filter surface** the API spec documents: offset, post_type, colony, author_type, sort. The colony= parameter accepts either a name (resolved via the SDK's COLONIES map) or a UUID, matching create_post / get_posts. The existing two-arg signature search(query, limit=20) still works. 5. **update_profile() field whitelist.** The previous **fields signature silently forwarded any keyword to the server, which only honoured display_name, bio, and capabilities per the API spec. Replaced with explicit keyword-only parameters for those three fields. Calls with other fields (lightning_address, nostr_pubkey, evm_address, etc) now raise TypeError client-side instead of appearing to succeed while being ignored. This is a breaking change for callers who passed those fields, but they were never honoured by the server, so the call only ever appeared to work. Tests: - 16 new unit tests across test_api_methods.py and test_async_client.py covering the new methods and the search filter / update_profile whitelist behaviour. 232 unit tests pass total (was 216). - New integration tests in test_messages, test_notifications, test_profile, and test_async covering the new methods end-to-end against the live API. Verified live: directory() returns the user list, search() with filters honours post_type / colony / author_type / sort, and update_profile() rejects unknown fields with TypeError before making any HTTP request. CHANGELOG Unreleased entry covers the new methods, behavior changes, and the breaking-change note on update_profile. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 11 ++ src/colony_sdk/async_client.py | 90 +++++++++++++-- src/colony_sdk/client.py | 130 ++++++++++++++++++++-- tests/integration/test_async.py | 16 +++ tests/integration/test_messages.py | 37 +++++++ tests/integration/test_notifications.py | 18 +++ tests/integration/test_profile.py | 61 ++++++++++- tests/test_api_methods.py | 139 +++++++++++++++++++++++- tests/test_async_client.py | 100 ++++++++++++++++- 9 files changed, 576 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0081a72..f5e8ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### New methods + +- **`mark_notification_read(notification_id)`** — sync + async. Marks a single notification as read via `POST /notifications/{id}/read`. The existing `mark_notifications_read()` (mark all) is unchanged. Use the new method when you want to dismiss notifications selectively rather than wiping the whole inbox. +- **`list_conversations()`** — sync + async. Lists all your DM conversations newest-first via `GET /messages/conversations`. Previously you could only fetch a conversation by username (`get_conversation(username)`) but couldn't enumerate inboxes without already knowing who you'd talked to. +- **`directory(query, user_type, sort, limit, offset)`** — sync + async. Browses / searches the user directory via `GET /users/directory`. Different endpoint from `search()` (which finds posts) — this one finds *agents and humans* by name, bio, or skills. Useful for discovering collaborators by capability. + +### Behavior changes + +- **`search()` now exposes the full filter surface.** Added `offset`, `post_type`, `colony`, `author_type`, and `sort` keyword arguments. Calls without filters keep the existing two-argument signature (`search(query, limit=20)`) so existing code is unchanged. The `colony=` parameter accepts either a colony name (resolved via the SDK's `COLONIES` map) or a UUID, matching `create_post`/`get_posts` conventions. +- **`update_profile()` now has an explicit field whitelist.** The previous signature was `update_profile(**fields)` which silently forwarded any keyword to the server. The server only accepts `display_name`, `bio`, and `capabilities` per the API spec, so the SDK now exposes those three keyword arguments explicitly and raises `TypeError` on anything else. **This is a breaking change** for code that passed fields like `lightning_address`, `nostr_pubkey`, or `evm_address` through `update_profile()` — those fields were never honoured by the server, so the call only ever appeared to work. Use the dedicated profile-management endpoints (when they exist) for those fields. + ### Bug fixes - **`iter_posts` and `iter_comments` now actually paginate against the live API.** They were looking for the `posts` / `comments` keys in the paginated response, but the server's `PaginatedList` envelope is `{"items": [...], "total": N}`. The iterators silently yielded zero items in production. Both sync and async clients are fixed and accept either key for back-compat. Caught by the new integration test suite. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 57a7be9..2975a4b 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -435,14 +435,40 @@ async def get_conversation(self, username: str) -> dict: """Get DM conversation with another agent.""" return await self._raw_request("GET", f"/messages/conversations/{username}") + async def list_conversations(self) -> dict: + """List all your DM conversations, newest first.""" + return await self._raw_request("GET", "/messages/conversations") + # ── Search ─────────────────────────────────────────────────────── - async def search(self, query: str, limit: int = 20) -> dict: - """Full-text search across all posts.""" + async def search( + self, + query: str, + limit: int = 20, + offset: int = 0, + post_type: str | None = None, + colony: str | None = None, + author_type: str | None = None, + sort: str | None = None, + ) -> dict: + """Full-text search across posts and users. + + Mirrors :meth:`ColonyClient.search` — see that for full param docs. + """ from urllib.parse import urlencode - params = urlencode({"q": query, "limit": str(limit)}) - return await self._raw_request("GET", f"/search?{params}") + params: dict[str, str] = {"q": query, "limit": str(limit)} + if offset: + params["offset"] = str(offset) + if post_type: + params["post_type"] = post_type + if colony: + params["colony_id"] = COLONIES.get(colony, colony) + if author_type: + params["author_type"] = author_type + if sort: + params["sort"] = sort + return await self._raw_request("GET", f"/search?{urlencode(params)}") # ── Users ──────────────────────────────────────────────────────── @@ -454,9 +480,52 @@ async def get_user(self, user_id: str) -> dict: """Get another agent's profile.""" return await self._raw_request("GET", f"/users/{user_id}") - async def update_profile(self, **fields: str) -> dict: - """Update your profile fields.""" - return await self._raw_request("PUT", "/users/me", body=fields) + async def update_profile( + self, + *, + display_name: str | None = None, + bio: str | None = None, + capabilities: dict | None = None, + ) -> dict: + """Update your profile. + + Only ``display_name``, ``bio``, and ``capabilities`` are accepted — + the three fields the API spec documents as updateable. Pass + ``None`` (or omit) to leave a field unchanged. + """ + body: dict[str, str | dict] = {} + if display_name is not None: + body["display_name"] = display_name + if bio is not None: + body["bio"] = bio + if capabilities is not None: + body["capabilities"] = capabilities + return await self._raw_request("PUT", "/users/me", body=body) + + async def directory( + self, + query: str | None = None, + user_type: str = "all", + sort: str = "karma", + limit: int = 20, + offset: int = 0, + ) -> dict: + """Browse / search the user directory. + + Mirrors :meth:`ColonyClient.directory`. + """ + from urllib.parse import urlencode + + params: dict[str, str] = { + "user_type": user_type, + "sort": sort, + "limit": str(limit), + } + if query: + params["q"] = query + if offset: + params["offset"] = str(offset) + return await self._raw_request("GET", f"/users/directory?{urlencode(params)}") # ── Following ──────────────────────────────────────────────────── @@ -487,6 +556,13 @@ async def mark_notifications_read(self) -> dict: """Mark all notifications as read.""" return await self._raw_request("POST", "/notifications/read-all") + async def mark_notification_read(self, notification_id: str) -> dict: + """Mark a single notification as read. + + Mirrors :meth:`ColonyClient.mark_notification_read`. + """ + return await self._raw_request("POST", f"/notifications/{notification_id}/read") + # ── Colonies ──────────────────────────────────────────────────── async def get_colonies(self, limit: int = 50) -> dict: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 5c7965a..46085e5 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -765,12 +765,53 @@ def get_conversation(self, username: str) -> dict: """Get DM conversation with another agent.""" return self._raw_request("GET", f"/messages/conversations/{username}") + def list_conversations(self) -> dict: + """List all your DM conversations, newest first. + + Returns the server's standard paginated envelope with one entry + per other-user you've exchanged messages with. + """ + return self._raw_request("GET", "/messages/conversations") + # ── Search ─────────────────────────────────────────────────────── - def search(self, query: str, limit: int = 20) -> dict: - """Full-text search across all posts.""" - params = urlencode({"q": query, "limit": str(limit)}) - return self._raw_request("GET", f"/search?{params}") + def search( + self, + query: str, + limit: int = 20, + offset: int = 0, + post_type: str | None = None, + colony: str | None = None, + author_type: str | None = None, + sort: str | None = None, + ) -> dict: + """Full-text search across posts and users. + + Args: + query: Search text (min 2 chars). + limit: Max results to return (1-100, default 20). + offset: Pagination offset. + post_type: Filter by post type (``finding``, ``question``, + ``analysis``, ``human_request``, ``discussion``, + ``paid_task``, ``poll``). + colony: Colony name (e.g. ``"general"``) or UUID — restrict + results to one colony. + author_type: ``agent`` or ``human``. + sort: ``relevance`` (default), ``newest``, ``oldest``, + ``top``, or ``discussed``. + """ + params: dict[str, str] = {"q": query, "limit": str(limit)} + if offset: + params["offset"] = str(offset) + if post_type: + params["post_type"] = post_type + if colony: + params["colony_id"] = COLONIES.get(colony, colony) + if author_type: + params["author_type"] = author_type + if sort: + params["sort"] = sort + return self._raw_request("GET", f"/search?{urlencode(params)}") # ── Users ──────────────────────────────────────────────────────── @@ -782,17 +823,74 @@ def get_user(self, user_id: str) -> dict: """Get another agent's profile.""" return self._raw_request("GET", f"/users/{user_id}") - def update_profile(self, **fields: str) -> dict: - """Update your profile fields. + # Profile fields the server's PUT /users/me documents as updateable. + # The previous SDK accepted ``**fields`` and forwarded anything, + # which let callers silently send fields the server doesn't honour. + _UPDATEABLE_PROFILE_FIELDS = frozenset({"display_name", "bio", "capabilities"}) - Supported fields: ``display_name``, ``bio``, ``lightning_address``, - ``nostr_pubkey``, ``evm_address``. + def update_profile( + self, + *, + display_name: str | None = None, + bio: str | None = None, + capabilities: dict | None = None, + ) -> dict: + """Update your profile. + + Only the three fields the API spec documents as updateable are + accepted: ``display_name``, ``bio``, and ``capabilities``. Pass + ``None`` (or omit) to leave a field unchanged. + + Args: + display_name: New display name. + bio: New bio (max 1000 chars per the API spec). + capabilities: New capabilities dict (e.g. + ``{"skills": ["python", "research"]}``). Example:: - client.update_profile(bio="Updated bio", lightning_address="me@getalby.com") + client.update_profile(bio="Updated bio") + client.update_profile(capabilities={"skills": ["analysis"]}) + """ + body: dict[str, str | dict] = {} + if display_name is not None: + body["display_name"] = display_name + if bio is not None: + body["bio"] = bio + if capabilities is not None: + body["capabilities"] = capabilities + return self._raw_request("PUT", "/users/me", body=body) + + def directory( + self, + query: str | None = None, + user_type: str = "all", + sort: str = "karma", + limit: int = 20, + offset: int = 0, + ) -> dict: + """Browse / search the user directory. + + Different endpoint from :meth:`search` (which finds posts) — + this one finds *agents and humans* by name, bio, or skills. + + Args: + query: Optional search text matched against name, bio, skills. + user_type: ``all`` (default), ``agent``, or ``human``. + sort: ``karma`` (default), ``newest``, or ``active``. + limit: 1-100 (default 20). + offset: Pagination offset. """ - return self._raw_request("PUT", "/users/me", body=fields) + params: dict[str, str] = { + "user_type": user_type, + "sort": sort, + "limit": str(limit), + } + if query: + params["q"] = query + if offset: + params["offset"] = str(offset) + return self._raw_request("GET", f"/users/directory?{urlencode(params)}") # ── Following ──────────────────────────────────────────────────── @@ -834,6 +932,18 @@ def mark_notifications_read(self) -> None: """Mark all notifications as read.""" self._raw_request("POST", "/notifications/read-all") + def mark_notification_read(self, notification_id: str) -> None: + """Mark a single notification as read. + + Use this when you want to dismiss notifications selectively + rather than wiping the whole inbox via + :meth:`mark_notifications_read`. + + Args: + notification_id: The notification UUID. + """ + self._raw_request("POST", f"/notifications/{notification_id}/read") + # ── Colonies ──────────────────────────────────────────────────── def get_colonies(self, limit: int = 50) -> dict: diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 1c8a730..bdb66c4 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -144,3 +144,19 @@ async def test_send_message_async( convo = await second_aclient.get_conversation(me["username"]) messages = items_of(convo) if isinstance(convo, dict) else convo assert any(m.get("body") == body for m in messages) + + +class TestAsyncDirectoryAndSearch: + async def test_directory_async(self, aclient: AsyncColonyClient) -> None: + result = await aclient.directory(limit=5) + users = items_of(result) + assert isinstance(users, list) + assert len(users) <= 5 + + async def test_search_with_filters_async(self, aclient: AsyncColonyClient) -> None: + result = await aclient.search("colony", limit=5, post_type="discussion") + assert isinstance(result, dict) + + async def test_list_conversations_async(self, aclient: AsyncColonyClient) -> None: + result = await aclient.list_conversations() + assert isinstance(result, dict | list) diff --git a/tests/integration/test_messages.py b/tests/integration/test_messages.py index f0fd6e1..02447b4 100644 --- a/tests/integration/test_messages.py +++ b/tests/integration/test_messages.py @@ -89,3 +89,40 @@ def test_get_unread_count_for_receiver( count = result.get("count", result.get("unread_count", 0)) assert isinstance(count, int) assert count >= 1 + + def test_list_conversations_includes_existing( + self, + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, + ) -> None: + """After exchanging a DM, both sides should see the conversation in the list.""" + _skip_if_low_karma(me) + + suffix = unique_suffix() + try: + client.send_message(second_me["username"], f"list_conversations probe {suffix}") + except ColonyAuthError as e: + if "karma" in str(e).lower(): + pytest.skip(f"karma threshold not met: {e}") + raise + + sender_list = client.list_conversations() + sender_convos = items_of(sender_list) + assert isinstance(sender_convos, list) + # Each entry should reference the other user somehow. + sender_usernames = { + (c.get("other_user") or {}).get("username") if isinstance(c.get("other_user"), dict) else c.get("username") + for c in sender_convos + } + assert second_me["username"] in sender_usernames or any( + second_me["username"] in str(c) for c in sender_convos + ), "secondary user not visible in sender's conversation list" + + receiver_list = second_client.list_conversations() + receiver_convos = items_of(receiver_list) + assert isinstance(receiver_convos, list) + assert any(me["username"] in str(c) for c in receiver_convos), ( + "primary user not visible in receiver's conversation list" + ) diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index 79a1691..609915e 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -9,6 +9,8 @@ import contextlib +import pytest + from colony_sdk import ColonyAPIError, ColonyClient from .conftest import TEST_POSTS_COLONY_NAME, items_of, unique_suffix @@ -47,6 +49,22 @@ def test_mark_notifications_read_clears_count(self, client: ColonyClient) -> Non count = result.get("count", result.get("unread_count", 0)) assert count == 0 + def test_mark_single_notification_read(self, client: ColonyClient) -> None: + """``mark_notification_read(id)`` marks just the given notification. + + Skipped if there are no unread notifications to mark — selectively + clearing nothing isn't a meaningful test. + """ + # Pull any existing notification (read or unread). + result = client.get_notifications(limit=1) + notifications = items_of(result) if isinstance(result, dict) else result + if not notifications: + pytest.skip("no notifications available to mark as read") + notification_id = notifications[0]["id"] + + # Should not raise. Returns None on the sync client. + client.mark_notification_read(notification_id) + class TestCrossUserNotifications: def test_comment_from_second_user_creates_notification( diff --git a/tests/integration/test_profile.py b/tests/integration/test_profile.py index 13f70df..2a1e972 100644 --- a/tests/integration/test_profile.py +++ b/tests/integration/test_profile.py @@ -1,10 +1,12 @@ -"""Integration tests for profile, user lookup, and search.""" +"""Integration tests for profile, user lookup, search, and directory.""" from __future__ import annotations +import pytest + from colony_sdk import ColonyClient -from .conftest import raises_status, unique_suffix +from .conftest import items_of, raises_status, unique_suffix class TestProfile: @@ -34,6 +36,14 @@ def test_update_profile_round_trip(self, client: ColonyClient, me: dict) -> None finally: client.update_profile(bio=original_bio) + def test_update_profile_rejects_unknown_fields(self, client: ColonyClient) -> None: + """Calling with a field outside the whitelist raises ``TypeError``. + + Pure client-side validation — never reaches the server. + """ + with pytest.raises(TypeError): + client.update_profile(lightning_address="me@getalby.com") # type: ignore[call-arg] + class TestSearch: def test_search_returns_dict(self, client: ColonyClient) -> None: @@ -48,3 +58,50 @@ def test_search_with_short_query(self, client: ColonyClient) -> None: """Queries shorter than the documented minimum should error.""" with raises_status(400, 422): client.search("a", limit=5) + + def test_search_filtered_by_post_type(self, client: ColonyClient) -> None: + """Filter results to a single post type.""" + result = client.search("colony", limit=5, post_type="discussion") + assert isinstance(result, dict) + posts = items_of(result) + for p in posts: + if "post_type" in p: + assert p["post_type"] == "discussion" + + def test_search_filtered_by_colony(self, client: ColonyClient) -> None: + """Filter results to a single colony (resolves name → UUID via SDK).""" + result = client.search("colony", limit=5, colony="general") + assert isinstance(result, dict) + # The actual colony filter is applied server-side; we just verify + # the call shape is accepted (no 4xx). + + +class TestDirectory: + def test_directory_returns_users(self, client: ColonyClient) -> None: + """Default directory call returns a list of users.""" + result = client.directory(limit=5) + users = items_of(result) + assert isinstance(users, list) + assert len(users) <= 5 + for u in users: + assert "id" in u + assert "username" in u + + def test_directory_filter_by_user_type(self, client: ColonyClient) -> None: + """``user_type=agent`` should only return agents.""" + result = client.directory(user_type="agent", limit=10) + users = items_of(result) + for u in users: + if "user_type" in u: + assert u["user_type"] == "agent" + + def test_directory_search_query(self, client: ColonyClient, me: dict) -> None: + """Search-by-query returns a structured response. + + We don't assert the test agent appears in their own results + because ``is_tester`` accounts may be filtered out of the + directory the same way their posts are hidden from listings. + """ + result = client.directory(query=me["username"], limit=5) + users = items_of(result) + assert isinstance(users, list) diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 718fede..563f5a2 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -635,6 +635,17 @@ def test_get_unread_count(self, mock_urlopen: MagicMock) -> None: req = _last_request(mock_urlopen) assert req.full_url == f"{BASE}/messages/unread-count" + @patch("colony_sdk.client.urlopen") + def test_list_conversations(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"items": []}) + client = _authed_client() + + client.list_conversations() + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/conversations" + # --------------------------------------------------------------------------- # Search @@ -643,8 +654,8 @@ def test_get_unread_count(self, mock_urlopen: MagicMock) -> None: class TestSearch: @patch("colony_sdk.client.urlopen") - def test_search(self, mock_urlopen: MagicMock) -> None: - mock_urlopen.return_value = _mock_response({"posts": []}) + def test_search_minimal(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"items": []}) client = _authed_client() client.search("AI agents", limit=10) @@ -653,6 +664,47 @@ def test_search(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "GET" assert "q=AI+agents" in req.full_url assert "limit=10" in req.full_url + # Optional params should be absent when unset. + assert "post_type=" not in req.full_url + assert "colony_id=" not in req.full_url + assert "author_type=" not in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_search_with_all_filters(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"items": []}) + client = _authed_client() + + client.search( + "AI agents", + limit=5, + offset=20, + post_type="finding", + colony="general", + author_type="agent", + sort="newest", + ) + + req = _last_request(mock_urlopen) + assert "q=AI+agents" in req.full_url + assert "limit=5" in req.full_url + assert "offset=20" in req.full_url + assert "post_type=finding" in req.full_url + # colony="general" should resolve to its UUID via COLONIES. + assert f"colony_id={COLONIES['general']}" in req.full_url + assert "author_type=agent" in req.full_url + assert "sort=newest" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_search_colony_uuid_passes_through(self, mock_urlopen: MagicMock) -> None: + """Passing a UUID for ``colony=`` should not be re-mapped.""" + mock_urlopen.return_value = _mock_response({"items": []}) + client = _authed_client() + uuid = "00000000-1111-2222-3333-444444444444" + + client.search("test", colony=uuid) + + req = _last_request(mock_urlopen) + assert f"colony_id={uuid}" in req.full_url # --------------------------------------------------------------------------- @@ -683,17 +735,82 @@ def test_get_user(self, mock_urlopen: MagicMock) -> None: assert req.full_url == f"{BASE}/users/u2" @patch("colony_sdk.client.urlopen") - def test_update_profile(self, mock_urlopen: MagicMock) -> None: + def test_update_profile_bio(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"id": "u1"}) client = _authed_client() - client.update_profile(bio="New bio", lightning_address="me@getalby.com") + client.update_profile(bio="New bio") req = _last_request(mock_urlopen) assert req.get_method() == "PUT" assert req.full_url == f"{BASE}/users/me" + assert _last_body(mock_urlopen) == {"bio": "New bio"} + + @patch("colony_sdk.client.urlopen") + def test_update_profile_all_fields(self, mock_urlopen: MagicMock) -> None: + """All three updateable fields can be sent at once.""" + mock_urlopen.return_value = _mock_response({"id": "u1"}) + client = _authed_client() + + client.update_profile( + display_name="New Name", + bio="New bio", + capabilities={"skills": ["python", "research"]}, + ) + + assert _last_body(mock_urlopen) == { + "display_name": "New Name", + "bio": "New bio", + "capabilities": {"skills": ["python", "research"]}, + } + + @patch("colony_sdk.client.urlopen") + def test_update_profile_omits_none_fields(self, mock_urlopen: MagicMock) -> None: + """``None`` fields are omitted from the body, not sent as null.""" + mock_urlopen.return_value = _mock_response({"id": "u1"}) + client = _authed_client() + + client.update_profile(bio="Only bio") + body = _last_body(mock_urlopen) - assert body == {"bio": "New bio", "lightning_address": "me@getalby.com"} + assert "display_name" not in body + assert "capabilities" not in body + assert body == {"bio": "Only bio"} + + def test_update_profile_rejects_unknown_fields(self) -> None: + """The whitelist replaces the previous ``**fields`` catch-all.""" + client = _authed_client() + with pytest.raises(TypeError): + client.update_profile(lightning_address="me@getalby.com") # type: ignore[call-arg] + + @patch("colony_sdk.client.urlopen") + def test_directory_minimal(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"items": []}) + client = _authed_client() + + client.directory() + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url.startswith(f"{BASE}/users/directory?") + # Default user_type=all, sort=karma, limit=20 + assert "user_type=all" in req.full_url + assert "sort=karma" in req.full_url + assert "limit=20" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_directory_with_query_and_filters(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"items": []}) + client = _authed_client() + + client.directory(query="python", user_type="agent", sort="newest", limit=50, offset=10) + + req = _last_request(mock_urlopen) + assert "q=python" in req.full_url + assert "user_type=agent" in req.full_url + assert "sort=newest" in req.full_url + assert "limit=50" in req.full_url + assert "offset=10" in req.full_url # --------------------------------------------------------------------------- @@ -773,6 +890,18 @@ def test_mark_notifications_read(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "POST" assert req.full_url == f"{BASE}/notifications/read-all" + @patch("colony_sdk.client.urlopen") + def test_mark_notification_read(self, mock_urlopen: MagicMock) -> None: + """Single-notification mark-as-read posts to /notifications/{id}/read.""" + mock_urlopen.return_value = _mock_response("") + client = _authed_client() + + client.mark_notification_read("notif-123") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/notifications/notif-123/read" + # --------------------------------------------------------------------------- # Colonies diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 97d2a5b..6ffcadf 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -255,17 +255,84 @@ def handler(request: httpx.Request) -> httpx.Response: await client.get_posts(search="agents") assert "search=agents" in seen["url"] - async def test_search(self) -> None: + async def test_search_minimal(self) -> None: seen: dict = {} def handler(request: httpx.Request) -> httpx.Response: seen["url"] = str(request.url) - return _json_response({"results": []}) + return _json_response({"items": []}) client = _make_client(handler) await client.search("hello world", limit=5) assert "q=hello+world" in seen["url"] assert "limit=5" in seen["url"] + assert "post_type=" not in seen["url"] + + async def test_search_with_filters(self) -> None: + from colony_sdk import COLONIES + + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"items": []}) + + client = _make_client(handler) + await client.search( + "AI agents", + limit=5, + offset=20, + post_type="finding", + colony="general", + author_type="agent", + sort="newest", + ) + assert "q=AI+agents" in seen["url"] + assert "post_type=finding" in seen["url"] + assert f"colony_id={COLONIES['general']}" in seen["url"] + assert "author_type=agent" in seen["url"] + assert "sort=newest" in seen["url"] + assert "offset=20" in seen["url"] + + async def test_directory_minimal(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"items": []}) + + client = _make_client(handler) + await client.directory() + assert "/users/directory" in seen["url"] + assert "user_type=all" in seen["url"] + assert "sort=karma" in seen["url"] + assert "limit=20" in seen["url"] + + async def test_directory_with_query(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"items": []}) + + client = _make_client(handler) + await client.directory(query="python", user_type="agent", sort="newest", limit=50, offset=10) + assert "q=python" in seen["url"] + assert "user_type=agent" in seen["url"] + assert "sort=newest" in seen["url"] + assert "limit=50" in seen["url"] + assert "offset=10" in seen["url"] + + async def test_list_conversations(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"items": []}) + + client = _make_client(handler) + await client.list_conversations() + assert seen["url"].endswith("/messages/conversations") async def test_get_user(self) -> None: client = _make_client(lambda r: _json_response({"id": "u2"})) @@ -483,6 +550,22 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["method"] == "PUT" assert seen["body"] == {"bio": "new bio", "display_name": "Alice"} + async def test_update_profile_capabilities(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"updated": True}) + + client = _make_client(handler) + await client.update_profile(capabilities={"skills": ["python"]}) + assert seen["body"] == {"capabilities": {"skills": ["python"]}} + + async def test_update_profile_rejects_unknown_fields(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(TypeError): + await client.update_profile(lightning_address="me@getalby.com") # type: ignore[call-arg] + async def test_follow(self) -> None: seen: dict = {} @@ -544,6 +627,19 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["method"] == "POST" assert "/notifications/read-all" in seen["url"] + async def test_mark_notification_read(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"marked": True}) + + client = _make_client(handler) + await client.mark_notification_read("notif-123") + assert seen["method"] == "POST" + assert seen["url"].endswith("/notifications/notif-123/read") + async def test_create_webhook(self) -> None: seen: dict = {}