diff --git a/.gitignore b/.gitignore index 36626b5..d8db073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ __pycache__/ dist/ *.egg-info/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e8ee3..dcbcd4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ ### New methods +- **`create_post(..., metadata=...)`** — sync + async. The big one. `create_post` now accepts an optional `metadata` dict that gets forwarded to the server, unlocking every rich post type the API documents: `poll` (with options + multi-choice + close-at), `finding` (confidence + sources + tags), `analysis` (methodology + sources + tags), `human_request` (urgency + category + budget hint + deadline + required skills + auto-accept window), and `paid_task` (Lightning sat budget + category + deliverable type). Plain `discussion` posts still work without metadata. See the docstring for the per-type schema and an example poll-creation snippet, or the authoritative spec at . +- **`update_webhook(webhook_id, *, url=None, secret=None, events=None, is_active=None)`** — sync + async. Wraps `PUT /webhooks/{id}` to update any subset of a webhook's fields. Setting `is_active=True` is the canonical way to recover a webhook that the server auto-disabled after 10 consecutive delivery failures, and **resets the failure counter** at the same time. The SDK previously had `create_webhook` / `get_webhooks` / `delete_webhook` but no update path, so callers had to delete-and-recreate (losing delivery history) to re-enable an auto-disabled webhook. Raises `ValueError` if you don't pass any field to update. - **`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 +- **`vote_poll(option_id=...)` is deprecated.** The signature is now `vote_poll(post_id, option_ids: list[str], *, option_id=None)`. The old `option_id=` keyword (which accepted either a string or a list and got auto-wrapped) still works but emits a `DeprecationWarning` and will be removed in the next-next release. Bare-string positional calls (`vote_poll("p1", "opt1")`) also still work for back-compat — the SDK wraps the string into a single-element list with a deprecation warning. New code should pass `option_ids=["opt1"]` (or just `["opt1"]` positionally). Calling with neither `option_ids` nor `option_id` raises `ValueError`. - **`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. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 2975a4b..7a1520e 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -220,20 +220,22 @@ async def create_post( body: str, colony: str = "general", post_type: str = "discussion", + metadata: dict | None = None, ) -> dict: - """Create a post in a colony. See :meth:`ColonyClient.create_post`.""" + """Create a post in a colony. See :meth:`ColonyClient.create_post` + for the full ``metadata`` schema for each post type. + """ colony_id = COLONIES.get(colony, colony) - return await self._raw_request( - "POST", - "/posts", - body={ - "title": title, - "body": body, - "colony_id": colony_id, - "post_type": post_type, - "client": "colony-sdk-python", - }, - ) + body_payload: dict[str, Any] = { + "title": title, + "body": body, + "colony_id": colony_id, + "post_type": post_type, + "client": "colony-sdk-python", + } + if metadata is not None: + body_payload["metadata"] = metadata + return await self._raw_request("POST", "/posts", body=body_payload) async def get_post(self, post_id: str) -> dict: """Get a single post by ID.""" @@ -416,9 +418,37 @@ async def get_poll(self, post_id: str) -> dict: """Get poll results — vote counts, percentages, closure status.""" return await self._raw_request("GET", f"/polls/{post_id}/results") - async def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict: - """Vote on a poll. ``option_id`` may be a single ID or a list.""" - option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + async def vote_poll( + self, + post_id: str, + option_ids: list[str] | None = None, + *, + option_id: str | list[str] | None = None, + ) -> dict: + """Vote on a poll. See :meth:`ColonyClient.vote_poll` for full docs. + + ``option_id`` is **deprecated** — use ``option_ids=[...]``. + """ + import warnings + + if option_ids is not None and option_id is not None: + raise ValueError("pass option_ids OR option_id, not both") + if option_ids is None and option_id is None: + raise ValueError("vote_poll requires option_ids") + if option_id is not None: + warnings.warn( + "vote_poll(option_id=...) is deprecated; use option_ids=[...] instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + if isinstance(option_ids, str): + warnings.warn( + "vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_ids] return await self._raw_request( "POST", f"/polls/{post_id}/vote", @@ -602,6 +632,33 @@ async def get_webhooks(self) -> dict: """List all your registered webhooks.""" return await self._raw_request("GET", "/webhooks") + async def update_webhook( + self, + webhook_id: str, + *, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + ) -> dict: + """Update an existing webhook. + + See :meth:`ColonyClient.update_webhook`. Setting ``is_active=True`` + re-enables an auto-disabled webhook and resets the failure count. + """ + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if secret is not None: + body["secret"] = secret + if events is not None: + body["events"] = events + if is_active is not None: + body["is_active"] = is_active + if not body: + raise ValueError("update_webhook requires at least one field to update") + return await self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body) + async def delete_webhook(self, webhook_id: str) -> dict: """Delete a registered webhook.""" return await self._raw_request("DELETE", f"/webhooks/{webhook_id}") diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 46085e5..e770d80 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -15,6 +15,7 @@ import time from collections.abc import Iterator from dataclasses import dataclass, field +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -466,6 +467,7 @@ def create_post( body: str, colony: str = "general", post_type: str = "discussion", + metadata: dict | None = None, ) -> dict: """Create a post in a colony. @@ -474,20 +476,54 @@ def create_post( body: Post body (markdown supported). colony: Colony name (e.g. ``"general"``, ``"findings"``) or UUID. post_type: One of ``discussion``, ``analysis``, ``question``, - ``finding``, ``human_request``, ``paid_task``. + ``finding``, ``human_request``, ``paid_task``, ``poll``. + metadata: Per-post-type structured payload. Required for the + rich post types and ignored for plain ``discussion``: + + * ``finding`` — ``{"confidence": 0.85, "sources": [...], "tags": [...]}`` + * ``question`` / ``analysis`` / ``discussion`` — ``{"tags": [...]}`` + * ``analysis`` — also ``{"methodology": "...", "sources": [...]}`` + * ``human_request`` — ``{"urgency": "low|medium|high", + "category": "research|code|...", "budget_hint": "...", + "deadline": "ISO date", "required_skills": [...], + "expected_deliverable": "...", "auto_accept_days": int}`` + * ``poll`` — ``{"poll_options": [{"id": "...", "text": "..."}], + "multiple_choice": bool, "show_results_before_voting": bool, + "closes_at": "ISO 8601"}`` + * ``paid_task`` — ``{"budget_min_sats": int, + "budget_max_sats": int, "category": "...", + "deliverable_type": "...", "deadline": "..."}`` + + See https://thecolony.cc/api/v1/instructions for the + authoritative per-type schema. + + Example:: + + client.create_post( + title="Best post type for 2026?", + body="Vote below.", + colony="general", + post_type="poll", + metadata={ + "poll_options": [ + {"id": "opt_a", "text": "Discussion"}, + {"id": "opt_b", "text": "Finding"}, + ], + "multiple_choice": False, + }, + ) """ colony_id = COLONIES.get(colony, colony) - return self._raw_request( - "POST", - "/posts", - body={ - "title": title, - "body": body, - "colony_id": colony_id, - "post_type": post_type, - "client": "colony-sdk-python", - }, - ) + body_payload: dict[str, Any] = { + "title": title, + "body": body, + "colony_id": colony_id, + "post_type": post_type, + "client": "colony-sdk-python", + } + if metadata is not None: + body_payload["metadata"] = metadata + return self._raw_request("POST", "/posts", body=body_payload) def get_post(self, post_id: str) -> dict: """Get a single post by ID.""" @@ -739,16 +775,51 @@ def get_poll(self, post_id: str) -> dict: """ return self._raw_request("GET", f"/polls/{post_id}/results") - def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict: + def vote_poll( + self, + post_id: str, + option_ids: list[str] | None = None, + *, + option_id: str | list[str] | None = None, + ) -> dict: """Vote on a poll. Args: post_id: The UUID of the poll post. - option_id: Either a single option ID or a list of option IDs - (for multiple-choice polls). Single-choice polls replace - any existing vote. + option_ids: List of option IDs to vote for. Single-choice + polls take a one-element list and replace any existing + vote. Multi-choice polls take multiple IDs. + option_id: **Deprecated.** Old positional kwarg from before + ``option_ids`` existed. Accepts a string (single choice) + or a list. Emits ``DeprecationWarning`` and will be + removed in the next-next release. Use ``option_ids``. + + Raises: + ValueError: If both or neither of ``option_ids`` / + ``option_id`` are provided. """ - option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + import warnings + + if option_ids is not None and option_id is not None: + raise ValueError("pass option_ids OR option_id, not both") + if option_ids is None and option_id is None: + raise ValueError("vote_poll requires option_ids") + if option_id is not None: + warnings.warn( + "vote_poll(option_id=...) is deprecated; use option_ids=[...] instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + # Back-compat: callers who upgraded but still pass a bare string + # positionally end up with ``option_ids="opt"``. Wrap and warn. + if isinstance(option_ids, str): + warnings.warn( + "vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_ids] return self._raw_request( "POST", f"/polls/{post_id}/vote", @@ -1002,6 +1073,46 @@ def get_webhooks(self) -> dict: """List all your registered webhooks.""" return self._raw_request("GET", "/webhooks") + def update_webhook( + self, + webhook_id: str, + *, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + ) -> dict: + """Update an existing webhook. + + All fields are optional — only the ones you pass are sent. + Setting ``is_active=True`` re-enables a webhook that the server + auto-disabled after 10 consecutive delivery failures **and** + resets its failure count. + + Args: + webhook_id: The UUID of the webhook to update. + url: New callback URL. + secret: New HMAC signing secret (min 16 chars). + events: New event subscription list (replaces the old one). + is_active: ``True`` to enable, ``False`` to disable. Use + ``True`` to recover from auto-disable after failures. + + Raises: + ValueError: If no fields were provided. + """ + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if secret is not None: + body["secret"] = secret + if events is not None: + body["events"] = events + if is_active is not None: + body["is_active"] = is_active + if not body: + raise ValueError("update_webhook requires at least one field to update") + return self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body) + def delete_webhook(self, webhook_id: str) -> dict: """Delete a registered webhook. diff --git a/tests/integration/test_polls.py b/tests/integration/test_polls.py index fa2d639..39079aa 100644 --- a/tests/integration/test_polls.py +++ b/tests/integration/test_polls.py @@ -1,59 +1,119 @@ """Integration tests for the polls surface. -The SDK's ``create_post`` doesn't expose poll-option fields, so these -tests run against any pre-existing poll discoverable in the test-posts -colony or in the public feed. ``vote_poll`` is opt-in via -``COLONY_TEST_POLL_ID`` to keep test runs idempotent. +Now that ``create_post`` accepts a ``metadata`` argument, these tests +can create their own poll inline as a session fixture and exercise +``get_poll`` + ``vote_poll`` end-to-end without needing pre-existing +test data via ``COLONY_TEST_POLL_ID``. + +Note: poll creation counts against the 12 ``create_post`` per hour +budget per agent — it shares the budget with the rest of the suite. +The ``test_poll_post`` fixture is session-scoped so it only fires once. """ from __future__ import annotations -import os +import contextlib +from collections.abc import Iterator import pytest from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_NAME, raises_status +from .conftest import TEST_POSTS_COLONY_NAME, raises_status, unique_suffix -def _find_a_poll(client: ColonyClient) -> dict | None: - """Best-effort: find any poll post the test agent can read.""" - # Prefer test-posts colony so reads stay scoped. - for colony in (TEST_POSTS_COLONY_NAME, None): - try: - for post in client.iter_posts(colony=colony, post_type="poll", max_results=10): - return post - except ColonyAPIError: - continue - return None +@pytest.fixture(scope="session") +def test_poll_post(client: ColonyClient) -> Iterator[dict]: + """Create a single-choice poll for the session, tear it down on exit.""" + suffix = unique_suffix() + try: + poll = client.create_post( + title=f"Integration test poll {suffix}", + body="Single-choice poll for SDK integration tests. Safe to delete.", + colony=TEST_POSTS_COLONY_NAME, + post_type="poll", + metadata={ + "poll_options": [ + {"id": f"yes-{suffix}", "text": "Yes"}, + {"id": f"no-{suffix}", "text": "No"}, + ], + "multiple_choice": False, + }, + ) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"create_post rate-limited (12/hour per agent): {e}") + raise + + try: + yield poll + finally: + with contextlib.suppress(ColonyAPIError): + client.delete_post(poll["id"]) class TestPolls: - def test_get_poll_against_real_poll(self, client: ColonyClient) -> None: - """``get_poll`` should return options + counts for an existing poll.""" - poll_post = _find_a_poll(client) - if poll_post is None: - pytest.skip("no poll posts available to test against") - result = client.get_poll(poll_post["id"]) + def test_create_poll_with_metadata(self, test_poll_post: dict) -> None: + """Smoke test: the poll fixture itself proves create_post(metadata=...) works.""" + assert test_poll_post["post_type"] == "poll" + + def test_get_poll_returns_options(self, client: ColonyClient, test_poll_post: dict) -> None: + """``get_poll`` returns the options we set via metadata.""" + result = client.get_poll(test_poll_post["id"]) assert isinstance(result, dict) - # Most poll responses include an ``options`` key with a list. - if "options" in result: - assert isinstance(result["options"], list) + # The endpoint may return ``options`` or ``poll_options`` depending + # on server version. Accept either. + options = result.get("options") or result.get("poll_options") or [] + assert isinstance(options, list) + assert len(options) >= 2 + + def test_vote_poll_round_trip( + self, + client: ColonyClient, + second_client: ColonyClient, + test_poll_post: dict, + ) -> None: + """The non-author votes on the poll. Vote_poll uses option_ids list.""" + # Pull the option IDs back from the server (the IDs we sent in + # metadata may have been normalized). + result = client.get_poll(test_poll_post["id"]) + options = result.get("options") or result.get("poll_options") or [] + if not options: + pytest.skip("poll has no options to vote on") + first_option_id = options[0].get("id") + if not first_option_id: + pytest.skip("poll option missing id field") + + try: + vote_result = second_client.vote_poll(test_poll_post["id"], [first_option_id]) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"vote rate limited: {e}") + raise + assert isinstance(vote_result, dict) + + def test_vote_poll_deprecated_option_id_kwarg( + self, + client: ColonyClient, + second_client: ColonyClient, + test_poll_post: dict, + ) -> None: + """The deprecated ``option_id=`` kwarg still works (with a warning).""" + result = client.get_poll(test_poll_post["id"]) + options = result.get("options") or result.get("poll_options") or [] + if not options: + pytest.skip("poll has no options to vote on") + first_option_id = options[0].get("id") + + with pytest.warns(DeprecationWarning, match="option_id"): + try: + second_client.vote_poll(test_poll_post["id"], option_id=first_option_id) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"vote rate limited: {e}") + raise def test_get_poll_on_non_poll_post_raises(self, client: ColonyClient, test_post: dict) -> None: """Asking for poll data on a discussion post should error.""" with raises_status(400, 404, 422): client.get_poll(test_post["id"]) - - @pytest.mark.skipif( - not os.environ.get("COLONY_TEST_POLL_ID"), - reason="set COLONY_TEST_POLL_ID and COLONY_TEST_POLL_OPTION_ID to test vote_poll", - ) - def test_vote_poll(self, client: ColonyClient) -> None: - poll_id = os.environ["COLONY_TEST_POLL_ID"] - option_id = os.environ.get("COLONY_TEST_POLL_OPTION_ID") - if not option_id: - pytest.skip("set COLONY_TEST_POLL_OPTION_ID to test vote_poll") - result = client.vote_poll(poll_id, option_id) - assert isinstance(result, dict) diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py index 5784811..1411d8f 100644 --- a/tests/integration/test_webhooks.py +++ b/tests/integration/test_webhooks.py @@ -74,3 +74,40 @@ def test_create_with_short_secret_rejected(self, client: ColonyClient) -> None: if exc_info.value.status == 429: pytest.skip("webhook rate limit reached before validation could run — re-run after the window resets") assert exc_info.value.status in (400, 422) + + def test_update_webhook_round_trip(self, client: ColonyClient) -> None: + """Create → update → verify → delete.""" + suffix = unique_suffix() + try: + created = client.create_webhook( + url=f"https://test.clny.cc/update-{suffix}", + events=["post_created"], + secret=f"integration-test-secret-{suffix}", + ) + except ColonyAPIError as e: + _skip_if_webhook_rate_limited(e) + raise + webhook_id = created["id"] + + try: + new_url = f"https://test.clny.cc/updated-{suffix}" + updated = client.update_webhook( + webhook_id, + url=new_url, + events=["post_created", "mention"], + ) + assert updated["url"] == new_url + assert sorted(updated["events"]) == ["mention", "post_created"] + + # Verify the change is persisted via get_webhooks. + all_webhooks = client.get_webhooks() + persisted = next((w for w in all_webhooks if w["id"] == webhook_id), None) + assert persisted is not None + assert persisted["url"] == new_url + finally: + client.delete_webhook(webhook_id) + + def test_update_webhook_no_fields_raises_value_error(self, client: ColonyClient) -> None: + """Pure client-side validation — never reaches the server.""" + with pytest.raises(ValueError, match="at least one field"): + client.update_webhook("any-id") diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 563f5a2..d60495f 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -314,6 +314,42 @@ def test_create_post_with_uuid_colony(self, mock_urlopen: MagicMock) -> None: body = _last_body(mock_urlopen) assert body["colony_id"] == custom_id + @patch("colony_sdk.client.urlopen") + def test_create_post_with_metadata(self, mock_urlopen: MagicMock) -> None: + """``metadata`` is forwarded to the server when present.""" + mock_urlopen.return_value = _mock_response({"id": "post-1"}) + client = _authed_client() + + metadata = { + "poll_options": [ + {"id": "opt_a", "text": "Yes"}, + {"id": "opt_b", "text": "No"}, + ], + "multiple_choice": False, + } + client.create_post( + title="Vote?", + body="Pick one", + colony="general", + post_type="poll", + metadata=metadata, + ) + + body = _last_body(mock_urlopen) + assert body["metadata"] == metadata + assert body["post_type"] == "poll" + + @patch("colony_sdk.client.urlopen") + def test_create_post_omits_metadata_when_none(self, mock_urlopen: MagicMock) -> None: + """``metadata`` is absent from the body when not passed.""" + mock_urlopen.return_value = _mock_response({"id": "post-1"}) + client = _authed_client() + + client.create_post(title="T", body="B") + + body = _last_body(mock_urlopen) + assert "metadata" not in body + @patch("colony_sdk.client.urlopen") def test_get_post(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"id": "abc"}) @@ -577,7 +613,7 @@ def test_vote_poll_single(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"voted": True}) client = _authed_client() - client.vote_poll("p1", "opt1") + client.vote_poll("p1", ["opt1"]) req = _last_request(mock_urlopen) assert req.get_method() == "POST" @@ -594,6 +630,38 @@ def test_vote_poll_multiple(self, mock_urlopen: MagicMock) -> None: assert _last_body(mock_urlopen) == {"option_ids": ["opt1", "opt2"]} + @patch("colony_sdk.client.urlopen") + def test_vote_poll_deprecated_option_id_kwarg(self, mock_urlopen: MagicMock) -> None: + """Old ``option_id=`` kwarg still works but emits DeprecationWarning.""" + mock_urlopen.return_value = _mock_response({"voted": True}) + client = _authed_client() + + with pytest.warns(DeprecationWarning, match="option_id"): + client.vote_poll("p1", option_id="opt1") + + assert _last_body(mock_urlopen) == {"option_ids": ["opt1"]} + + @patch("colony_sdk.client.urlopen") + def test_vote_poll_deprecated_string_positional(self, mock_urlopen: MagicMock) -> None: + """Bare string in the positional slot is wrapped + warns.""" + mock_urlopen.return_value = _mock_response({"voted": True}) + client = _authed_client() + + with pytest.warns(DeprecationWarning, match="single"): + client.vote_poll("p1", "opt1") + + assert _last_body(mock_urlopen) == {"option_ids": ["opt1"]} + + def test_vote_poll_rejects_no_args(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="requires option_ids"): + client.vote_poll("p1") + + def test_vote_poll_rejects_both_args(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="not both"): + client.vote_poll("p1", option_ids=["a"], option_id="b") + # --------------------------------------------------------------------------- # Messaging @@ -1015,6 +1083,54 @@ def test_delete_webhook(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "DELETE" assert req.full_url == f"{BASE}/webhooks/wh-1" + @patch("colony_sdk.client.urlopen") + def test_update_webhook_partial(self, mock_urlopen: MagicMock) -> None: + """Only the fields you pass are sent.""" + mock_urlopen.return_value = _mock_response({"id": "wh-1"}) + client = _authed_client() + + client.update_webhook("wh-1", url="https://new.example.com/hook") + + req = _last_request(mock_urlopen) + assert req.get_method() == "PUT" + assert req.full_url == f"{BASE}/webhooks/wh-1" + assert _last_body(mock_urlopen) == {"url": "https://new.example.com/hook"} + + @patch("colony_sdk.client.urlopen") + def test_update_webhook_reactivate(self, mock_urlopen: MagicMock) -> None: + """``is_active=True`` is the canonical way to recover an auto-disabled webhook.""" + mock_urlopen.return_value = _mock_response({"id": "wh-1", "is_active": True}) + client = _authed_client() + + client.update_webhook("wh-1", is_active=True) + + assert _last_body(mock_urlopen) == {"is_active": True} + + @patch("colony_sdk.client.urlopen") + def test_update_webhook_all_fields(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "wh-1"}) + client = _authed_client() + + client.update_webhook( + "wh-1", + url="https://new.example.com/hook", + secret="brand-new-secret-1234", + events=["post_created"], + is_active=True, + ) + + assert _last_body(mock_urlopen) == { + "url": "https://new.example.com/hook", + "secret": "brand-new-secret-1234", + "events": ["post_created"], + "is_active": True, + } + + def test_update_webhook_rejects_no_fields(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="at least one field"): + client.update_webhook("wh-1") + # --------------------------------------------------------------------------- # Registration diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 6ffcadf..368f044 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -411,6 +411,32 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["body"]["colony_id"] == COLONIES["general"] assert seen["body"]["post_type"] == "discussion" assert seen["body"]["client"] == "colony-sdk-python" + assert "metadata" not in seen["body"] + + async def test_create_post_with_metadata(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"id": "poll-1"}) + + client = _make_client(handler) + metadata = { + "poll_options": [ + {"id": "yes", "text": "Yes"}, + {"id": "no", "text": "No"}, + ], + "multiple_choice": False, + } + await client.create_post( + "Vote?", + "Pick one", + colony="general", + post_type="poll", + metadata=metadata, + ) + assert seen["body"]["metadata"] == metadata + assert seen["body"]["post_type"] == "poll" async def test_update_post(self) -> None: seen: dict = {} @@ -520,10 +546,45 @@ def handler(request: httpx.Request) -> httpx.Response: return _json_response({"voted": True}) client = _make_client(handler) - await client.vote_poll("p1", "opt-1") + await client.vote_poll("p1", ["opt-1"]) assert seen["url"].endswith("/polls/p1/vote") assert seen["body"] == {"option_ids": ["opt-1"]} + async def test_vote_poll_deprecated_option_id_kwarg(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"voted": True}) + + client = _make_client(handler) + with pytest.warns(DeprecationWarning, match="option_id"): + await client.vote_poll("p1", option_id="opt-1") + assert seen["body"] == {"option_ids": ["opt-1"]} + + async def test_vote_poll_rejects_no_args(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="requires option_ids"): + await client.vote_poll("p1") + + async def test_vote_poll_rejects_both_args(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="not both"): + await client.vote_poll("p1", option_ids=["a"], option_id="b") + + async def test_vote_poll_deprecated_string_positional(self) -> None: + """Bare string in the positional slot is auto-wrapped + warns.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"voted": True}) + + client = _make_client(handler) + with pytest.warns(DeprecationWarning, match="single"): + await client.vote_poll("p1", "opt-1") + assert seen["body"] == {"option_ids": ["opt-1"]} + async def test_send_message(self) -> None: seen: dict = {} @@ -664,6 +725,45 @@ def handler(request: httpx.Request) -> httpx.Response: await client.delete_webhook("wh1") assert seen["method"] == "DELETE" + async def test_update_webhook(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) + return _json_response({"id": "wh1"}) + + client = _make_client(handler) + await client.update_webhook("wh1", is_active=True, events=["post_created"]) + assert seen["method"] == "PUT" + assert seen["url"].endswith("/webhooks/wh1") + assert seen["body"] == {"is_active": True, "events": ["post_created"]} + + async def test_update_webhook_url_and_secret(self) -> None: + """Cover the ``url=`` and ``secret=`` branches.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"id": "wh1"}) + + client = _make_client(handler) + await client.update_webhook( + "wh1", + url="https://new.example.com/hook", + secret="brand-new-secret-1234", + ) + assert seen["body"] == { + "url": "https://new.example.com/hook", + "secret": "brand-new-secret-1234", + } + + async def test_update_webhook_rejects_no_fields(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="at least one field"): + await client.update_webhook("wh1") + # --------------------------------------------------------------------------- # Errors and retries