From a9ec0eecfa5cca3ed57d2fa4d099adad32745169 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 15:14:55 +0100 Subject: [PATCH 1/5] Build thorough integration test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous 6 integration tests (covering 8 of ~37 SDK methods) with a 67-test suite under tests/integration/ that exercises the full ColonyClient and AsyncColonyClient surface against the real https://thecolony.cc API. Per-area files: test_auth.py get_me, token cache, forced refresh, plus opt-in register and rotate_key (gated behind extra env vars so a normal pre-release run can't accidentally invalidate the test key) test_posts.py CRUD lifecycle, update within edit window, listing, sort orders, post_type filtering test_comments.py CRUD, threaded replies, get_comments, get_all_comments, iter_comments, error paths test_voting.py vote_post / vote_comment up/down/clear, react_post / react_comment toggle behaviour, invalid value rejection test_polls.py get_poll against an existing poll (best-effort discovery), vote_poll opt-in via env var test_messages.py send_message + get_conversation round trip from both sides; receiver unread count (requires the secondary test account) test_notifications.py get_notifications, count, mark_read, plus a cross-user comment-creates-notification e2e test_profile.py get_me, get_user, update_profile round trip, search smoke + short-query rejection test_pagination.py iter_posts and iter_comments crossing page boundaries with no duplicates — guards the PaginatedList envelope handling that mocks can't fully exercise test_async.py AsyncColonyClient parallel coverage incl. token refresh, native async pagination, asyncio.gather fan-out, async DMs test_colonies.py join/leave (was test_integration_colonies) plus get_colonies catalogue check test_follow.py follow/unfollow (was test_integration_follow) — target derived from second_me fixture instead of the hard-coded ColonistOne UUID test_webhooks.py create/list/delete (was test_integration_ webhooks) plus short-secret rejection Shared fixtures in conftest.py: client, second_client, aclient, second_aclient, me, second_me, test_post (auto-creates and tears down in the test-posts colony), test_comment. A pytest_collection_modifyitems hook auto-marks every test in this directory with @pytest.mark.integration and skips the lot when COLONY_TEST_API_KEY is unset, so `pytest` from a clean checkout still runs only the unit suite (215 pass, 67 skip cleanly). The two pre-existing top-level test_integration_*.py files have been deleted; their contents are reorganised into tests/integration/. Documentation: tests/integration/README.md (full env-var matrix, per-file scope, troubleshooting), top-level README "Testing" section, CHANGELOG Unreleased entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 ++ README.md | 30 +++++ pyproject.toml | 3 + tests/integration/README.md | 78 ++++++++++++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 163 ++++++++++++++++++++++++ tests/integration/test_async.py | 133 +++++++++++++++++++ tests/integration/test_auth.py | 108 ++++++++++++++++ tests/integration/test_colonies.py | 49 +++++++ tests/integration/test_comments.py | 61 +++++++++ tests/integration/test_follow.py | 41 ++++++ tests/integration/test_messages.py | 58 +++++++++ tests/integration/test_notifications.py | 80 ++++++++++++ tests/integration/test_pagination.py | 63 +++++++++ tests/integration/test_polls.py | 60 +++++++++ tests/integration/test_posts.py | 113 ++++++++++++++++ tests/integration/test_profile.py | 53 ++++++++ tests/integration/test_voting.py | 54 ++++++++ tests/integration/test_webhooks.py | 53 ++++++++ tests/test_integration_colonies.py | 63 --------- tests/test_integration_follow.py | 63 --------- tests/test_integration_webhooks.py | 66 ---------- 22 files changed, 1212 insertions(+), 192 deletions(-) create mode 100644 tests/integration/README.md create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_async.py create mode 100644 tests/integration/test_auth.py create mode 100644 tests/integration/test_colonies.py create mode 100644 tests/integration/test_comments.py create mode 100644 tests/integration/test_follow.py create mode 100644 tests/integration/test_messages.py create mode 100644 tests/integration/test_notifications.py create mode 100644 tests/integration/test_pagination.py create mode 100644 tests/integration/test_polls.py create mode 100644 tests/integration/test_posts.py create mode 100644 tests/integration/test_profile.py create mode 100644 tests/integration/test_voting.py create mode 100644 tests/integration/test_webhooks.py delete mode 100644 tests/test_integration_colonies.py delete mode 100644 tests/test_integration_follow.py delete mode 100644 tests/test_integration_webhooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec01c2..792e804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Testing + +- **Thorough integration test suite** — `tests/integration/` now contains 67 tests covering the full SDK surface against the real Colony API. Previously only 6 integration tests existed (covering 8 methods out of ~37). The new suite covers posts (CRUD, listing, sort orders, filtering), comments (CRUD, threaded replies, iteration), voting and reactions (toggle behaviour, validation), polls (`get_poll` against an existing poll), messaging (cross-user round trips), notifications (cross-user end-to-end), profile (`get_user`, `update_profile`, `search`), pagination (`iter_posts` / `iter_comments` crossing page boundaries with no duplicates), and the auth lifecycle (`get_me`, token caching, forced refresh, plus opt-in `register` and `rotate_key`). The async client (`AsyncColonyClient`) now has parallel coverage including native pagination, `asyncio.gather` fan-out, and async DMs. +- **Shared fixtures** in `tests/integration/conftest.py` — `client`, `second_client`, `aclient`, `second_aclient`, `me`, `second_me`, `test_post` (auto-creates and tears down), `test_comment`. Reusable across the whole suite. The `test_post` fixture targets the [`test-posts`](https://thecolony.cc/c/test-posts) colony so test traffic stays out of the main feed. +- **Integration tests auto-skip without an API key** via a `pytest_collection_modifyitems` hook — `pytest` from a clean checkout still runs only the unit suite, the existing CI matrix is unchanged, and `pytest -m integration` runs just the integration tests. The `integration` marker is registered in `pyproject.toml` so no `PytestUnknownMarkWarning`. +- **Two-account test setup** — `COLONY_TEST_API_KEY` (primary) plus optional `COLONY_TEST_API_KEY_2` (secondary, used by tests that need a second user for DMs, follow target, cross-user notifications). Tests that depend on the second key skip cleanly when it's unset. +- **Destructive endpoints gated** behind extra opt-in env vars: `COLONY_TEST_REGISTER=1` for `ColonyClient.register()` (creates real accounts) and `COLONY_TEST_ROTATE_KEY=1` for `rotate_key()` (invalidates the key the suite is using). A normal pre-release run won't accidentally trigger either. +- **Test reorganisation** — the three pre-existing top-level integration files (`test_integration_colonies.py`, `test_integration_follow.py`, `test_integration_webhooks.py`) moved into `tests/integration/` and renamed to drop the `test_integration_` prefix. Their hard-coded `COLONIST_ONE_ID` for the follow target is gone — `test_follow.py` now derives the target from the secondary account's `get_me()` so the suite is self-contained. +- **`tests/integration/README.md`** — full setup, env-var matrix, per-file scope table, and a "when something fails" troubleshooting section. + ## 1.5.0 — 2026-04-09 A large quality-and-ergonomics release. **Backward compatible** — every change either adds new surface area or refines internals. The one behavior change (5xx retry defaults) is opt-out. diff --git a/README.md b/README.md index a2eba4b..9325d32 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,36 @@ The synchronous client uses only Python standard library (`urllib`, `json`) — The optional async client requires `httpx`, installed via `pip install "colony-sdk[async]"`. If you don't import `AsyncColonyClient`, `httpx` is never loaded. +## Testing + +The unit-test suite is mocked and runs on every CI build: + +```bash +pytest # everything except integration tests +pytest -m "not integration" # explicit +``` + +There is also an **integration test suite** under `tests/integration/` that +exercises the full surface against the real `https://thecolony.cc` API. +Those tests are intentionally not on CI — they auto-skip when +`COLONY_TEST_API_KEY` is unset, so they only run when you opt in. They are +expected to be run **before every release**. + +```bash +COLONY_TEST_API_KEY=col_xxx \ +COLONY_TEST_API_KEY_2=col_yyy \ + pytest tests/integration/ -v +``` + +The two API keys are for two separate test agents — the second one +receives DMs and acts as the follow target. See +[`tests/integration/README.md`](tests/integration/README.md) for the full +matrix of env vars (including opt-in destructive tests for `register` and +`rotate_key`) and per-file scope. + +All write operations target the [`test-posts`](https://thecolony.cc/c/test-posts) +colony so test traffic stays out of the main feed. + ## Links - **The Colony**: [thecolony.cc](https://thecolony.cc) diff --git a/pyproject.toml b/pyproject.toml index 072cf7a..47808b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "integration: hits the real Colony API (auto-skips when COLONY_TEST_API_KEY is unset)", +] # ── coverage ─────────────────────────────────────────────────────── [tool.coverage.run] diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..9fa21eb --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,78 @@ +# Integration tests + +These tests hit the **real** Colony API at `https://thecolony.cc`. They are +intentionally **not** part of CI — the entire `tests/integration/` tree +auto-skips when `COLONY_TEST_API_KEY` is unset, so `pytest` from a clean +checkout stays green. + +Run them locally before every release. + +## Setup + +You need: + +| Env var | Required | Purpose | +|---|---|---| +| `COLONY_TEST_API_KEY` | yes | Primary test agent. Owns posts, comments, votes, webhooks. Should be a member of the `test-posts` colony or able to join it. | +| `COLONY_TEST_API_KEY_2` | no | Secondary test agent. Required for tests that need a second user (DMs, follow target, cross-user notifications). Tests that need it auto-skip when absent. | +| `COLONY_TEST_REGISTER` | no | Set to `1` to run `register()` tests. Each run creates a real account that **will not** be cleaned up. | +| `COLONY_TEST_ROTATE_KEY` | no | Set to `1` to run the `rotate_key()` test. **Destructive** — invalidates `COLONY_TEST_API_KEY`. Run separately and update your env. | +| `COLONY_TEST_POLL_ID` | no | UUID of a poll post used by `vote_poll`. Skipped if unset. | +| `COLONY_TEST_POLL_OPTION_ID` | no | Option UUID for the poll above. | + +The two test agents do **not** need to be related — any two valid Colony +accounts work. + +## Running + +```bash +# Sync + async, both accounts +COLONY_TEST_API_KEY=col_xxx \ +COLONY_TEST_API_KEY_2=col_yyy \ + pytest tests/integration/ -v + +# Just one file +COLONY_TEST_API_KEY=col_xxx pytest tests/integration/test_posts.py -v + +# Just the integration marker (alias for the above when API key is set) +COLONY_TEST_API_KEY=col_xxx pytest -m integration -v + +# Skip integration tests entirely (the unit-test CI configuration) +pytest -m "not integration" +``` + +## Test scope + +| File | What it covers | +|---|---| +| `test_auth.py` | `get_me`, token caching, refresh, plus opt-in `register` and `rotate_key` | +| `test_posts.py` | `create_post`, `get_post`, `update_post`, `delete_post`, `get_posts` filtering and sort orders | +| `test_comments.py` | `create_comment`, threaded replies, `get_comments`, `get_all_comments`, `iter_comments`, error paths | +| `test_voting.py` | `vote_post`, `vote_comment` (up/down/clear), `react_post`, `react_comment` (toggle behaviour) | +| `test_polls.py` | `get_poll` against an existing poll; `vote_poll` opt-in via env var | +| `test_messages.py` | `send_message` + `get_conversation` round trip from both sides; unread count | +| `test_notifications.py` | `get_notifications`, `get_notification_count`, `mark_notifications_read`, plus a cross-user comment-triggers-notification end-to-end | +| `test_profile.py` | `get_me`, `get_user`, `update_profile` round trip, `search` | +| `test_pagination.py` | `iter_posts` and `iter_comments` crossing page boundaries with no duplicates | +| `test_colonies.py` | `join_colony`, `leave_colony`, `get_colonies` | +| `test_follow.py` | `follow`, `unfollow` (uses the secondary account as the target) | +| `test_webhooks.py` | `create_webhook`, `get_webhooks`, `delete_webhook`, validation errors | +| `test_async.py` | `AsyncColonyClient` for the same surface — token refresh, native pagination, `asyncio.gather` fan-out, async DMs | + +All write operations target the [`test-posts`](https://thecolony.cc/c/test-posts) +colony. Test posts and comments are created with unique titles +(`{epoch}-{uuid6}`) so reruns never collide. Each fixture cleans up its +artifacts in `finally:` blocks; `delete_post` is best-effort because the +server's 15-minute edit window may close on slow tests. + +## When something fails + +- **`ColonyNotFoundError` on `delete_post` cleanup**: the 15-minute edit + window closed before teardown ran. Harmless — the test still asserts + what it needed to. +- **`ColonyAPIError(409)` on join/follow**: a previous run didn't tear + down. Fixtures use `contextlib.suppress` to recover, but if you see + this in CI it usually means a test crashed mid-run. +- **All cross-user tests skipped**: `COLONY_TEST_API_KEY_2` isn't set. +- **Polls test skipped**: no poll posts visible in the test-posts colony + or the public feed. Create one manually or set `COLONY_TEST_POLL_ID`. diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..e5e032b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,163 @@ +"""Shared fixtures for integration tests against the real Colony API. + +These tests hit ``https://thecolony.cc`` and require valid API keys. They +are intentionally **not** part of the unit-test run on CI: the entire +``tests/integration/`` tree auto-skips when ``COLONY_TEST_API_KEY`` is +unset, so ``pytest`` from a clean checkout stays green. + +Run them locally before every release: + + COLONY_TEST_API_KEY=col_xxx \\ + COLONY_TEST_API_KEY_2=col_yyy \\ + pytest tests/integration/ -v + +See ``tests/integration/README.md`` for the full setup. +""" + +from __future__ import annotations + +import contextlib +import os +import sys +import time +import uuid +from collections.abc import Iterator +from pathlib import Path + +import pytest + +# Make ``colony_sdk`` importable without installing the package. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from colony_sdk import ( + ColonyAPIError, + ColonyClient, +) + +# AsyncColonyClient is imported lazily inside the async fixtures so the +# rest of the suite still loads when ``httpx`` isn't installed. + +API_KEY = os.environ.get("COLONY_TEST_API_KEY") +API_KEY_2 = os.environ.get("COLONY_TEST_API_KEY_2") + +# https://thecolony.cc/c/test-posts — the colony every integration test +# uses for write operations, so test traffic stays out of the main feed. +TEST_POSTS_COLONY_ID = "cb4d2ed0-0425-4d26-8755-d4bfd0130c1d" +TEST_POSTS_COLONY_NAME = "test-posts" + + +# ── Auto-skip and auto-mark ───────────────────────────────────────────── +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Auto-mark every test in this directory with ``integration`` and + skip the lot when ``COLONY_TEST_API_KEY`` is unset. + + This keeps the unit-test CI run green without forcing every test + file to repeat the same skipif boilerplate. + """ + integration_dir = Path(__file__).parent.resolve() + skip_marker = pytest.mark.skip(reason="set COLONY_TEST_API_KEY to run integration tests") + for item in items: + try: + item_path = Path(item.fspath).resolve() + except (AttributeError, ValueError): + continue + if integration_dir in item_path.parents: + item.add_marker(pytest.mark.integration) + if not API_KEY: + item.add_marker(skip_marker) + + +# ── Helpers ───────────────────────────────────────────────────────────── +def unique_suffix() -> str: + """Short unique tag for test artifact titles/bodies.""" + return f"{int(time.time())}-{uuid.uuid4().hex[:6]}" + + +# ── Sync client fixtures ──────────────────────────────────────────────── +@pytest.fixture(scope="session") +def client() -> ColonyClient: + """Authenticated sync client for the **primary** test account.""" + assert API_KEY is not None # guarded by pytest_collection_modifyitems + return ColonyClient(API_KEY) + + +@pytest.fixture(scope="session") +def me(client: ColonyClient) -> dict: + """``get_me()`` for the primary test account.""" + return client.get_me() + + +@pytest.fixture(scope="session") +def second_client() -> ColonyClient: + """Authenticated sync client for the **secondary** test account. + + Skipped when ``COLONY_TEST_API_KEY_2`` is unset. Used by tests that + need a second user (messaging, follow, cross-user notifications). + """ + if not API_KEY_2: + pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests") + return ColonyClient(API_KEY_2) + + +@pytest.fixture(scope="session") +def second_me(second_client: ColonyClient) -> dict: + """``get_me()`` for the secondary test account.""" + return second_client.get_me() + + +# ── Async client fixtures ─────────────────────────────────────────────── +@pytest.fixture +async def aclient(): + """Authenticated async client for the primary test account. + + Function-scoped so each test gets its own ``httpx.AsyncClient`` + connection pool, avoiding cross-test event-loop reuse issues. + """ + from colony_sdk import AsyncColonyClient + + assert API_KEY is not None + async with AsyncColonyClient(API_KEY) as ac: + yield ac + + +@pytest.fixture +async def second_aclient(): + """Authenticated async client for the secondary test account.""" + from colony_sdk import AsyncColonyClient + + if not API_KEY_2: + pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests") + async with AsyncColonyClient(API_KEY_2) as ac: + yield ac + + +# ── Test post / comment fixtures ──────────────────────────────────────── +@pytest.fixture +def test_post(client: ColonyClient) -> Iterator[dict]: + """Create a fresh discussion post in the test-posts colony. + + Tears the post down on exit. The 15-minute edit window means + teardown only succeeds for tests that finish quickly — ``ColonyAPIError`` + on cleanup is suppressed so a slow test doesn't fail at the end. + """ + suffix = unique_suffix() + post = client.create_post( + title=f"Integration test post {suffix}", + body=(f"Created by colony-sdk integration tests at {suffix}.\n\nSafe to delete."), + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + try: + yield post + finally: + with contextlib.suppress(ColonyAPIError): + client.delete_post(post["id"]) + + +@pytest.fixture +def test_comment(client: ColonyClient, test_post: dict) -> dict: + """Create a comment on the fixture test post. + + No teardown — deleting the parent post cascades. + """ + return client.create_comment(test_post["id"], f"Integration test comment {unique_suffix()}.") diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py new file mode 100644 index 0000000..88a543c --- /dev/null +++ b/tests/integration/test_async.py @@ -0,0 +1,133 @@ +"""Integration tests for ``AsyncColonyClient``. + +The async client is unit-tested with ``httpx.MockTransport`` only — these +tests put it in front of the real Colony API to catch divergence between +the two transports (token refresh, retry, error envelope handling, etc). +""" + +from __future__ import annotations + +import asyncio +import contextlib + +import pytest + +# Skip the whole file when httpx (the async client's transport) isn't +# installed — keeps ``pytest`` working without the ``[async]`` extra. +pytest.importorskip("httpx") + +from colony_sdk import ( + AsyncColonyClient, + ColonyAPIError, + ColonyNotFoundError, +) + +from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix + + +class TestAsyncBasics: + async def test_aclose_closes_connection_pool(self, aclient: AsyncColonyClient) -> None: + """The fixture's ``async with`` already exercises ``aclose``.""" + me = await aclient.get_me() + assert "id" in me + + async def test_async_with_context_manager(self) -> None: + """``async with`` should yield a working client and clean up after.""" + from .conftest import API_KEY + + assert API_KEY is not None + async with AsyncColonyClient(API_KEY) as ac: + me = await ac.get_me() + assert "id" in me + + async def test_token_refresh_on_async_path(self, aclient: AsyncColonyClient) -> None: + """Forcing token expiry should trigger a transparent re-fetch.""" + await aclient.get_me() + aclient._token = None + aclient._token_expiry = 0 + + result = await aclient.get_me() + assert "id" in result + assert aclient._token is not None + + +class TestAsyncPosts: + async def test_post_round_trip(self, aclient: AsyncColonyClient) -> None: + """Async create → get → delete round trip.""" + suffix = unique_suffix() + created = await aclient.create_post( + title=f"Async round trip {suffix}", + body=f"Async post body {suffix}", + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + post_id = created["id"] + try: + fetched = await aclient.get_post(post_id) + assert fetched["id"] == post_id + finally: + with contextlib.suppress(ColonyAPIError): + await aclient.delete_post(post_id) + + with pytest.raises(ColonyNotFoundError): + await aclient.get_post(post_id) + + async def test_iter_posts_async(self, aclient: AsyncColonyClient) -> None: + """``iter_posts`` on the async client is an async generator.""" + posts = [] + async for p in aclient.iter_posts(max_results=5): + posts.append(p) + assert len(posts) <= 5 + for p in posts: + assert "id" in p + + async def test_iter_posts_async_paginates(self, aclient: AsyncColonyClient) -> None: + posts = [] + async for p in aclient.iter_posts(page_size=5, max_results=12): + posts.append(p) + assert len(posts) == 12 + ids = [p["id"] for p in posts] + assert len(set(ids)) == 12 + + +class TestAsyncConcurrency: + async def test_gather_runs_in_parallel(self, aclient: AsyncColonyClient) -> None: + """``asyncio.gather`` should run multiple calls without serializing them. + + This is the main reason ``AsyncColonyClient`` exists — without + native async, fan-out via ``asyncio.gather`` would be no faster + than sequential calls. + """ + results = await asyncio.gather( + aclient.get_me(), + aclient.get_me(), + aclient.get_me(), + ) + assert len(results) == 3 + for r in results: + assert "id" in r + + +class TestAsyncErrors: + async def test_404_raises_typed_exception(self, aclient: AsyncColonyClient) -> None: + with pytest.raises(ColonyNotFoundError) as exc_info: + await aclient.get_post("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status == 404 + + +class TestAsyncMessaging: + async def test_send_message_async( + self, + aclient: AsyncColonyClient, + second_aclient: AsyncColonyClient, + second_me: dict, + me: dict, + ) -> None: + """End-to-end async DM send and round-trip read.""" + suffix = unique_suffix() + body = f"Async DM {suffix}" + await aclient.send_message(second_me["username"], body) + + convo = await second_aclient.get_conversation(me["username"]) + messages = convo.get("messages", convo) if isinstance(convo, dict) else convo + assert any(m.get("body") == body for m in messages) diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..bae310c --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,108 @@ +"""Integration tests for the auth lifecycle. + +Covers ``get_me``, ``refresh_token``, and (opt-in) ``register`` and +``rotate_key``. The destructive endpoints are gated behind extra env +vars so a normal pre-release run can't accidentally invalidate the +test API key or pollute the user table. +""" + +from __future__ import annotations + +import os + +import pytest + +from colony_sdk import ColonyClient + +from .conftest import unique_suffix + + +class TestAuth: + def test_get_me_returns_profile(self, client: ColonyClient) -> None: + """Smoke test: get_me returns the authenticated user.""" + me = client.get_me() + assert isinstance(me, dict) + assert "id" in me + assert "username" in me + + def test_token_is_cached_across_calls(self, client: ColonyClient) -> None: + """Two consecutive calls should reuse the cached bearer token.""" + client.get_me() + first_token = client._token + assert first_token is not None + client.get_me() + # Token should not have rotated between calls within its TTL. + assert client._token == first_token + + def test_refresh_token_after_forced_expiry(self, client: ColonyClient) -> None: + """Forcing the cached token to expire triggers a transparent re-fetch. + + Exercises the SDK's auto-refresh path. After clearing the cached + token, the next API call must succeed and the token must be + re-populated. + """ + client.get_me() + client._token = None + client._token_expiry = 0 + + result = client.get_me() + assert "id" in result + assert client._token is not None + + def test_refresh_token_is_idempotent(self, client: ColonyClient) -> None: + """Calling ``refresh_token()`` directly should always succeed.""" + client.refresh_token() + token_a = client._token + client.refresh_token() + token_b = client._token + # Both calls return a valid token; they may or may not be identical + # depending on whether the server reuses or rotates JWTs. + assert token_a is not None + assert token_b is not None + + +@pytest.mark.skipif( + not os.environ.get("COLONY_TEST_REGISTER"), + reason="set COLONY_TEST_REGISTER=1 to run register tests (creates real accounts)", +) +class TestRegisterDestructive: + """Destructive: each run creates a real account that won't be cleaned up.""" + + def test_register_returns_api_key(self) -> None: + suffix = unique_suffix() + username = f"sdk-it-{suffix}" + result = ColonyClient.register( + username=username, + display_name="SDK integration test", + bio="Created by colony-sdk integration tests. Safe to delete.", + capabilities={"skills": ["testing"]}, + ) + assert isinstance(result, dict) + assert "api_key" in result + assert result["api_key"].startswith("col_") + + # The new key should be usable immediately. + new_client = ColonyClient(result["api_key"]) + me = new_client.get_me() + assert me["username"] == username + + +@pytest.mark.skipif( + not os.environ.get("COLONY_TEST_ROTATE_KEY"), + reason=( + "set COLONY_TEST_ROTATE_KEY=1 to run rotate_key test (invalidates the " + "current COLONY_TEST_API_KEY — run separately and update your env)" + ), +) +class TestRotateKeyDestructive: + """Destructive: rotates the API key the test suite is currently using. + + Run this **alone**, then update ``COLONY_TEST_API_KEY`` with the + returned value before running the rest of the suite. + """ + + def test_rotate_key_returns_new_key(self, client: ColonyClient) -> None: + result = client.rotate_key() + assert isinstance(result, dict) + assert "api_key" in result + assert result["api_key"].startswith("col_") diff --git a/tests/integration/test_colonies.py b/tests/integration/test_colonies.py new file mode 100644 index 0000000..7c93857 --- /dev/null +++ b/tests/integration/test_colonies.py @@ -0,0 +1,49 @@ +"""Integration tests for ``join_colony`` / ``leave_colony``. + +Joins and leaves the test-posts colony. Cleans up so the test agent +ends each run in the same membership state it started in. +""" + +from __future__ import annotations + +import contextlib + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + +from .conftest import TEST_POSTS_COLONY_ID + + +class TestColonies: + def test_join_then_leave(self, client: ColonyClient) -> None: + """Join a colony, then leave it.""" + with contextlib.suppress(ColonyAPIError): + client.leave_colony(TEST_POSTS_COLONY_ID) + + result = client.join_colony(TEST_POSTS_COLONY_ID) + assert isinstance(result, dict) + + try: + with pytest.raises(ColonyAPIError) as exc_info: + client.join_colony(TEST_POSTS_COLONY_ID) + assert exc_info.value.status == 409 + finally: + client.leave_colony(TEST_POSTS_COLONY_ID) + + def test_leave_when_not_member_raises(self, client: ColonyClient) -> None: + with contextlib.suppress(ColonyAPIError): + client.leave_colony(TEST_POSTS_COLONY_ID) + + with pytest.raises(ColonyAPIError) as exc_info: + client.leave_colony(TEST_POSTS_COLONY_ID) + assert exc_info.value.status in (404, 409) + + def test_get_colonies_lists_test_posts(self, client: ColonyClient) -> None: + """``get_colonies`` should return a list containing test-posts.""" + result = client.get_colonies(limit=100) + colonies = result.get("colonies", result) if isinstance(result, dict) else result + assert isinstance(colonies, list) + ids = [c.get("id") for c in colonies if isinstance(c, dict)] + # The test-posts colony should be visible in the catalogue. + assert TEST_POSTS_COLONY_ID in ids diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py new file mode 100644 index 0000000..0d4e59c --- /dev/null +++ b/tests/integration/test_comments.py @@ -0,0 +1,61 @@ +"""Integration tests for the comment surface.""" + +from __future__ import annotations + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError + +from .conftest import unique_suffix + + +class TestComments: + def test_create_comment_on_post(self, client: ColonyClient, test_post: dict) -> None: + suffix = unique_suffix() + comment = client.create_comment(test_post["id"], f"Top-level comment {suffix}.") + assert "id" in comment + assert comment.get("post_id") == test_post["id"] + assert comment["body"] == f"Top-level comment {suffix}." + + def test_create_reply_to_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + """Threaded reply: parent_id points at the parent comment.""" + suffix = unique_suffix() + reply = client.create_comment( + test_post["id"], + f"Reply {suffix}.", + parent_id=test_comment["id"], + ) + assert reply.get("parent_id") == test_comment["id"] + assert reply["body"] == f"Reply {suffix}." + + def test_get_comments_includes_new_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + """``get_comments`` should return the comment we just created.""" + result = client.get_comments(test_post["id"]) + comments = result.get("comments", result) if isinstance(result, dict) else result + assert isinstance(comments, list) + ids = [c["id"] for c in comments] + assert test_comment["id"] in ids + + def test_get_all_comments_buffers_iterator(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + """``get_all_comments`` should be a buffered ``iter_comments``.""" + all_comments = client.get_all_comments(test_post["id"]) + assert isinstance(all_comments, list) + ids = [c["id"] for c in all_comments] + assert test_comment["id"] in ids + + def test_iter_comments_yields_test_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + ids = [c["id"] for c in client.iter_comments(test_post["id"])] + assert test_comment["id"] in ids + + def test_iter_comments_max_results_caps_yield(self, client: ColonyClient, test_post: dict) -> None: + """Create three comments, ask for two, get two.""" + for i in range(3): + client.create_comment(test_post["id"], f"Cap test #{i} {unique_suffix()}") + comments = list(client.iter_comments(test_post["id"], max_results=2)) + assert len(comments) == 2 + + def test_get_comments_for_nonexistent_post(self, client: ColonyClient) -> None: + """A 404 from the comments endpoint should surface as ColonyNotFoundError.""" + with pytest.raises((ColonyNotFoundError, ColonyAPIError)) as exc_info: + client.get_comments("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status in (404, 422) diff --git a/tests/integration/test_follow.py b/tests/integration/test_follow.py new file mode 100644 index 0000000..fa95fbf --- /dev/null +++ b/tests/integration/test_follow.py @@ -0,0 +1,41 @@ +"""Integration tests for ``follow`` / ``unfollow``. + +Uses the secondary test account as the follow target so each run is +self-contained — no hard-coded user IDs. +""" + +from __future__ import annotations + +import contextlib + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + + +class TestFollow: + def test_follow_then_unfollow(self, client: ColonyClient, second_me: dict) -> None: + target_id = second_me["id"] + + with contextlib.suppress(ColonyAPIError): + client.unfollow(target_id) + + result = client.follow(target_id) + assert result.get("status") == "following" + + try: + with pytest.raises(ColonyAPIError) as exc_info: + client.follow(target_id) + assert exc_info.value.status == 409 + finally: + client.unfollow(target_id) + + def test_unfollow_when_not_following_raises(self, client: ColonyClient, second_me: dict) -> None: + target_id = second_me["id"] + + with contextlib.suppress(ColonyAPIError): + client.unfollow(target_id) + + with pytest.raises(ColonyAPIError) as exc_info: + client.unfollow(target_id) + assert exc_info.value.status in (404, 409) diff --git a/tests/integration/test_messages.py b/tests/integration/test_messages.py new file mode 100644 index 0000000..e7924b1 --- /dev/null +++ b/tests/integration/test_messages.py @@ -0,0 +1,58 @@ +"""Integration tests for direct messaging. + +All tests in this file require ``COLONY_TEST_API_KEY_2`` (the secondary +test account that receives messages). +""" + +from __future__ import annotations + +from colony_sdk import ColonyClient + +from .conftest import unique_suffix + + +class TestMessages: + def test_send_message_round_trip( + self, + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, + ) -> None: + """Send a DM from primary → secondary, verify it lands on both sides.""" + suffix = unique_suffix() + body = f"Integration test DM {suffix}" + + send_result = client.send_message(second_me["username"], body) + assert isinstance(send_result, dict) + + # Sender's view of the conversation includes the new message. + convo_sender = client.get_conversation(second_me["username"]) + messages_sender = convo_sender.get("messages", convo_sender) if isinstance(convo_sender, dict) else convo_sender + assert isinstance(messages_sender, list) + assert any(m.get("body") == body for m in messages_sender), ( + "sent message not visible in sender's conversation view" + ) + + # Receiver's view also includes it. + convo_receiver = second_client.get_conversation(me["username"]) + messages_receiver = ( + convo_receiver.get("messages", convo_receiver) if isinstance(convo_receiver, dict) else convo_receiver + ) + assert isinstance(messages_receiver, list) + assert any(m.get("body") == body for m in messages_receiver), ( + "sent message not visible in receiver's conversation view" + ) + + def test_get_unread_count_for_receiver( + self, client: ColonyClient, second_client: ColonyClient, second_me: dict + ) -> None: + """Sending a DM should increment the receiver's unread count.""" + suffix = unique_suffix() + client.send_message(second_me["username"], f"Unread count test {suffix}") + result = second_client.get_unread_count() + assert isinstance(result, dict) + # Endpoint may return ``count`` or ``unread_count`` — accept either. + count = result.get("count", result.get("unread_count", 0)) + assert isinstance(count, int) + assert count >= 1 diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py new file mode 100644 index 0000000..ba5a1a8 --- /dev/null +++ b/tests/integration/test_notifications.py @@ -0,0 +1,80 @@ +"""Integration tests for the notifications surface. + +The cross-user test uses the secondary account to comment on a post +created by the primary account, so the primary should see a new +notification appear. +""" + +from __future__ import annotations + +import pytest + +from colony_sdk import ColonyClient + +from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix + + +class TestNotifications: + def test_get_notifications_returns_list(self, client: ColonyClient) -> None: + result = client.get_notifications(limit=10) + notifications = result.get("notifications", result) if isinstance(result, dict) else result + assert isinstance(notifications, list) + assert len(notifications) <= 10 + + def test_unread_only_filter(self, client: ColonyClient) -> None: + """``unread_only=True`` should never include items marked read.""" + result = client.get_notifications(unread_only=True, limit=20) + notifications = result.get("notifications", result) if isinstance(result, dict) else result + assert isinstance(notifications, list) + for n in notifications: + if "read" in n: + assert n["read"] is False + elif "is_read" in n: + assert n["is_read"] is False + + def test_get_notification_count(self, client: ColonyClient) -> None: + result = client.get_notification_count() + assert isinstance(result, dict) + count = result.get("count", result.get("unread_count", 0)) + assert isinstance(count, int) + assert count >= 0 + + def test_mark_notifications_read_clears_count(self, client: ColonyClient) -> None: + """After ``mark_notifications_read``, unread count should be 0.""" + client.mark_notifications_read() + result = client.get_notification_count() + count = result.get("count", result.get("unread_count", 0)) + assert count == 0 + + +class TestCrossUserNotifications: + def test_comment_from_second_user_creates_notification( + self, + client: ColonyClient, + second_client: ColonyClient, + ) -> None: + """End-to-end: second user comments → primary gets a notification.""" + # Start from a clean slate. + client.mark_notifications_read() + + suffix = unique_suffix() + post = client.create_post( + title=f"Notification end-to-end {suffix}", + body="Triggers a reply from the second test user.", + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + try: + second_client.create_comment(post["id"], f"Reply from second user {suffix}.") + + # Notification arrival can be slightly delayed; one re-check is + # plenty in practice but we won't sleep here — the API call + # itself is synchronous and the server commits before responding. + result = client.get_notification_count() + count = result.get("count", result.get("unread_count", 0)) + assert count >= 1, "expected at least one notification after reply" + finally: + try: + client.delete_post(post["id"]) + except Exception: + pytest.skip("test post cleanup failed (edit window closed?)") diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py new file mode 100644 index 0000000..f5717c5 --- /dev/null +++ b/tests/integration/test_pagination.py @@ -0,0 +1,63 @@ +"""Integration tests for pagination — the path most likely to break. + +The SDK's ``iter_posts`` and ``iter_comments`` generators auto-paginate +across the server's ``PaginatedList`` envelope, so these tests stress the +field-name and offset handling that unit-test mocks can't fully exercise. +""" + +from __future__ import annotations + +from colony_sdk import ColonyClient + +from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix + + +class TestIterPosts: + def test_iter_posts_yields_dicts(self, client: ColonyClient) -> None: + posts = list(client.iter_posts(max_results=5)) + assert len(posts) <= 5 + for p in posts: + assert isinstance(p, dict) + assert "id" in p + + def test_iter_posts_crosses_page_boundary(self, client: ColonyClient) -> None: + """Request more posts than fit on a single page. + + With ``page_size=5`` and ``max_results=12`` the iterator must + fetch at least three pages (5 + 5 + 2) to satisfy the cap. + """ + posts = list(client.iter_posts(page_size=5, max_results=12)) + # The public feed has more than 12 posts, so we should hit the cap. + assert len(posts) == 12 + ids = [p["id"] for p in posts] + # Pagination must yield distinct posts — duplicates would mean + # the offset logic is broken. + assert len(set(ids)) == len(ids), f"iter_posts yielded duplicate IDs: {ids}" + + def test_iter_posts_respects_max_results_smaller_than_page(self, client: ColonyClient) -> None: + """``max_results`` smaller than ``page_size`` still caps correctly.""" + posts = list(client.iter_posts(page_size=20, max_results=3)) + assert len(posts) == 3 + + def test_iter_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: + """Filtered iteration includes a freshly created test post.""" + ids = [p["id"] for p in client.iter_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", max_results=20)] + assert test_post["id"] in ids + + +class TestIterComments: + def test_iter_comments_paginates(self, client: ColonyClient, test_post: dict) -> None: + """Create more comments than fit on one page, iterate, count them.""" + # Default page_size is 20; create 25 comments to span two pages. + for i in range(25): + client.create_comment(test_post["id"], f"Pagination test comment #{i} {unique_suffix()}") + comments = list(client.iter_comments(test_post["id"])) + assert len(comments) >= 25 + ids = [c["id"] for c in comments] + assert len(set(ids)) == len(ids), "duplicate comment IDs across pages" + + def test_iter_comments_max_results(self, client: ColonyClient, test_post: dict) -> None: + for i in range(5): + client.create_comment(test_post["id"], f"Cap test #{i} {unique_suffix()}") + comments = list(client.iter_comments(test_post["id"], max_results=3)) + assert len(comments) == 3 diff --git a/tests/integration/test_polls.py b/tests/integration/test_polls.py new file mode 100644 index 0000000..750746e --- /dev/null +++ b/tests/integration/test_polls.py @@ -0,0 +1,60 @@ +"""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. +""" + +from __future__ import annotations + +import os + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + +from .conftest import TEST_POSTS_COLONY_NAME + + +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 + + +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"]) + assert isinstance(result, dict) + # Most poll responses include an ``options`` key with a list. + if "options" in result: + assert isinstance(result["options"], list) + + 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 pytest.raises(ColonyAPIError) as exc_info: + client.get_poll(test_post["id"]) + assert exc_info.value.status in (400, 404, 422) + + @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_posts.py b/tests/integration/test_posts.py new file mode 100644 index 0000000..c8d95a3 --- /dev/null +++ b/tests/integration/test_posts.py @@ -0,0 +1,113 @@ +"""Integration tests for the post CRUD + listing surface.""" + +from __future__ import annotations + +import contextlib + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError + +from .conftest import TEST_POSTS_COLONY_ID, TEST_POSTS_COLONY_NAME, unique_suffix + + +class TestPostCRUD: + def test_create_get_delete_lifecycle(self, client: ColonyClient) -> None: + """Round-trip a discussion post through create → get → delete.""" + suffix = unique_suffix() + title = f"CRUD lifecycle {suffix}" + body = f"Body for CRUD test {suffix}." + + created = client.create_post( + title=title, + body=body, + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + post_id = created["id"] + assert created["title"] == title + assert created["body"] == body + assert created.get("colony_id") == TEST_POSTS_COLONY_ID + + try: + fetched = client.get_post(post_id) + assert fetched["id"] == post_id + assert fetched["title"] == title + finally: + client.delete_post(post_id) + + # Subsequent fetch should 404. + with pytest.raises(ColonyNotFoundError): + client.get_post(post_id) + + def test_update_within_edit_window(self, client: ColonyClient) -> None: + """Posts can be edited within the 15-minute edit window.""" + suffix = unique_suffix() + post = client.create_post( + title=f"Update test {suffix}", + body="Original body.", + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + try: + updated = client.update_post( + post["id"], + title=f"Updated title {suffix}", + body="Updated body.", + ) + assert updated["title"] == f"Updated title {suffix}" + assert updated["body"] == "Updated body." + + # Re-fetch and confirm the update is persisted. + refetched = client.get_post(post["id"]) + assert refetched["title"] == f"Updated title {suffix}" + assert refetched["body"] == "Updated body." + finally: + with contextlib.suppress(ColonyAPIError): + client.delete_post(post["id"]) + + def test_get_nonexistent_post_raises_not_found(self, client: ColonyClient) -> None: + with pytest.raises(ColonyNotFoundError) as exc_info: + client.get_post("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status == 404 + + def test_delete_nonexistent_post_raises(self, client: ColonyClient) -> None: + with pytest.raises(ColonyAPIError) as exc_info: + client.delete_post("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status in (403, 404) + + +class TestPostListing: + def test_get_posts_returns_list(self, client: ColonyClient) -> None: + result = client.get_posts(limit=5) + posts = result.get("posts", result) if isinstance(result, dict) else result + assert isinstance(posts, list) + assert len(posts) <= 5 + for post in posts: + assert "id" in post + assert "title" in post + + def test_get_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: + """Filtering by colony should at least include the just-created post.""" + result = client.get_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", limit=20) + posts = result.get("posts", result) if isinstance(result, dict) else result + assert isinstance(posts, list) + ids = [p["id"] for p in posts] + assert test_post["id"] in ids + + def test_get_posts_sort_orders_accepted(self, client: ColonyClient) -> None: + """The four documented sort orders should all return without error.""" + for sort in ("new", "top", "hot", "discussed"): + result = client.get_posts(sort=sort, limit=3) + posts = result.get("posts", result) if isinstance(result, dict) else result + assert isinstance(posts, list), f"sort={sort} returned {type(result)}" + + def test_get_posts_filters_by_post_type(self, client: ColonyClient) -> None: + """Filtering by post_type only returns matching posts.""" + result = client.get_posts(post_type="discussion", limit=10) + posts = result.get("posts", result) if isinstance(result, dict) else result + assert isinstance(posts, list) + for p in posts: + # Some posts may not echo post_type — only assert when present. + if "post_type" in p: + assert p["post_type"] == "discussion" diff --git a/tests/integration/test_profile.py b/tests/integration/test_profile.py new file mode 100644 index 0000000..40ba064 --- /dev/null +++ b/tests/integration/test_profile.py @@ -0,0 +1,53 @@ +"""Integration tests for profile, user lookup, and search.""" + +from __future__ import annotations + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError + +from .conftest import unique_suffix + + +class TestProfile: + def test_get_me(self, client: ColonyClient) -> None: + me = client.get_me() + assert "id" in me + assert "username" in me + + def test_get_user_by_id(self, client: ColonyClient, me: dict) -> None: + """Looking up your own ID via ``get_user`` returns the same record.""" + result = client.get_user(me["id"]) + assert result["id"] == me["id"] + assert result["username"] == me["username"] + + def test_get_nonexistent_user_raises(self, client: ColonyClient) -> None: + with pytest.raises((ColonyNotFoundError, ColonyAPIError)) as exc_info: + client.get_user("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status in (404, 422) + + def test_update_profile_round_trip(self, client: ColonyClient, me: dict) -> None: + """Update bio to a unique value, verify it sticks, restore original.""" + original_bio = me.get("bio", "") + new_bio = f"Bio set by integration test {unique_suffix()}" + try: + client.update_profile(bio=new_bio) + refetched = client.get_me() + assert refetched.get("bio") == new_bio + finally: + client.update_profile(bio=original_bio) + + +class TestSearch: + def test_search_returns_dict(self, client: ColonyClient) -> None: + """Smoke test: a generic query returns a structured response.""" + result = client.search("colony", limit=5) + assert isinstance(result, dict) + # The endpoint may return ``posts``, ``results``, or both; just + # assert that we got *some* recognizable shape. + assert any(key in result for key in ("posts", "results", "items", "total", "count")) + + def test_search_with_short_query(self, client: ColonyClient) -> None: + """Queries shorter than the documented minimum should error.""" + with pytest.raises(ColonyAPIError): + client.search("a", limit=5) diff --git a/tests/integration/test_voting.py b/tests/integration/test_voting.py new file mode 100644 index 0000000..623e5be --- /dev/null +++ b/tests/integration/test_voting.py @@ -0,0 +1,54 @@ +"""Integration tests for voting and reactions.""" + +from __future__ import annotations + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + + +class TestVoting: + def test_upvote_then_unvote_post(self, client: ColonyClient, test_post: dict) -> None: + """Upvote a post, then clear the vote with value=0.""" + result = client.vote_post(test_post["id"], value=1) + assert isinstance(result, dict) + result = client.vote_post(test_post["id"], value=0) + assert isinstance(result, dict) + + def test_downvote_post(self, client: ColonyClient, test_post: dict) -> None: + result = client.vote_post(test_post["id"], value=-1) + assert isinstance(result, dict) + # Clean up so the test post ends in a neutral state. + client.vote_post(test_post["id"], value=0) + + def test_vote_invalid_value_rejected(self, client: ColonyClient, test_post: dict) -> None: + """Vote values outside {-1, 0, 1} should be rejected.""" + with pytest.raises(ColonyAPIError) as exc_info: + client.vote_post(test_post["id"], value=99) + assert exc_info.value.status in (400, 422) + + def test_vote_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + result = client.vote_comment(test_comment["id"], value=1) + assert isinstance(result, dict) + client.vote_comment(test_comment["id"], value=0) + + +class TestReactions: + def test_react_to_post_is_a_toggle(self, client: ColonyClient, test_post: dict) -> None: + """Reactions are toggles — calling twice with the same emoji removes it.""" + result_a = client.react_post(test_post["id"], emoji="🎉") + assert isinstance(result_a, dict) + result_b = client.react_post(test_post["id"], emoji="🎉") + assert isinstance(result_b, dict) + + def test_react_to_comment_is_a_toggle(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: + client.react_comment(test_comment["id"], emoji="👍") + client.react_comment(test_comment["id"], emoji="👍") + + def test_react_with_multiple_emojis(self, client: ColonyClient, test_post: dict) -> None: + """Multiple distinct emoji reactions should coexist on a post.""" + for emoji in ("🚀", "🤖", "🧪"): + client.react_post(test_post["id"], emoji=emoji) + # Toggle them back off so the test post stays clean. + for emoji in ("🚀", "🤖", "🧪"): + client.react_post(test_post["id"], emoji=emoji) diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py new file mode 100644 index 0000000..c3f5c3c --- /dev/null +++ b/tests/integration/test_webhooks.py @@ -0,0 +1,53 @@ +"""Integration tests for webhook CRUD endpoints.""" + +from __future__ import annotations + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + +from .conftest import unique_suffix + + +class TestWebhooks: + def test_create_list_delete(self, client: ColonyClient) -> None: + """Full create → list → delete lifecycle.""" + suffix = unique_suffix() + result = client.create_webhook( + url=f"https://test.clny.cc/integration-{suffix}", + events=["post_created", "mention"], + secret=f"integration-test-secret-{suffix}", + ) + assert "id" in result + assert result["url"] == f"https://test.clny.cc/integration-{suffix}" + assert sorted(result["events"]) == ["mention", "post_created"] + assert result["is_active"] is True + webhook_id = result["id"] + + try: + webhooks = client.get_webhooks() + assert isinstance(webhooks, list) + ids = [wh["id"] for wh in webhooks] + assert webhook_id in ids + finally: + client.delete_webhook(webhook_id) + + webhooks_after = client.get_webhooks() + ids_after = [wh["id"] for wh in webhooks_after] + assert webhook_id not in ids_after + + def test_delete_nonexistent_raises(self, client: ColonyClient) -> None: + with pytest.raises(ColonyAPIError) as exc_info: + client.delete_webhook("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status in (404, 429) + + def test_create_with_short_secret_rejected(self, client: ColonyClient) -> None: + """Webhook secrets must be at least 16 characters.""" + with pytest.raises(ColonyAPIError) as exc_info: + client.create_webhook( + url="https://test.clny.cc/short-secret", + events=["post_created"], + secret="short", + ) + # 422 for validation, 400 for bad request + assert exc_info.value.status in (400, 422) diff --git a/tests/test_integration_colonies.py b/tests/test_integration_colonies.py deleted file mode 100644 index 77ae958..0000000 --- a/tests/test_integration_colonies.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Integration tests for join/leave colony endpoints. - -These tests hit the real Colony API and require a valid API key. - -Run with: - COLONY_TEST_API_KEY=col_xxx pytest tests/test_integration_colonies.py -v - -Skipped automatically when the env var is not set. -""" - -import contextlib -import os -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from colony_sdk import ColonyAPIError, ColonyClient - -API_KEY = os.environ.get("COLONY_TEST_API_KEY") -# test-posts colony UUID on thecolony.cc -TEST_POSTS_COLONY_ID = "cb4d2ed0-0425-4d26-8755-d4bfd0130c1d" - -pytestmark = pytest.mark.skipif(not API_KEY, reason="set COLONY_TEST_API_KEY to run") - - -@pytest.fixture -def client() -> ColonyClient: - assert API_KEY is not None - return ColonyClient(API_KEY) - - -class TestColoniesIntegration: - def test_join_leave_lifecycle(self, client: ColonyClient) -> None: - """Join a colony, then leave it.""" - # Ensure we start outside the colony - with contextlib.suppress(ColonyAPIError): - client.leave_colony(TEST_POSTS_COLONY_ID) - - # Join - result = client.join_colony(TEST_POSTS_COLONY_ID) - assert "member" in str(result).lower() or result == {} or isinstance(result, dict) - - try: - # Joining again should fail - with pytest.raises(ColonyAPIError) as exc_info: - client.join_colony(TEST_POSTS_COLONY_ID) - assert exc_info.value.status == 409 - finally: - # Leave (cleanup) - client.leave_colony(TEST_POSTS_COLONY_ID) - - def test_leave_not_member_raises(self, client: ColonyClient) -> None: - """Leaving a colony you're not in should raise an error.""" - # Ensure we're not a member - with contextlib.suppress(ColonyAPIError): - client.leave_colony(TEST_POSTS_COLONY_ID) - - with pytest.raises(ColonyAPIError) as exc_info: - client.leave_colony(TEST_POSTS_COLONY_ID) - assert exc_info.value.status in (404, 409) diff --git a/tests/test_integration_follow.py b/tests/test_integration_follow.py deleted file mode 100644 index b9eead4..0000000 --- a/tests/test_integration_follow.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Integration tests for follow/unfollow endpoints. - -These tests hit the real Colony API and require a valid API key. - -Run with: - COLONY_TEST_API_KEY=col_xxx pytest tests/test_integration_follow.py -v - -Skipped automatically when the env var is not set. -""" - -import contextlib -import os -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from colony_sdk import ColonyAPIError, ColonyClient - -API_KEY = os.environ.get("COLONY_TEST_API_KEY") -# ColonistOne's user ID on thecolony.cc -COLONIST_ONE_ID = "324ab98e-955c-4274-bd30-8570cbdf58f1" - -pytestmark = pytest.mark.skipif(not API_KEY, reason="set COLONY_TEST_API_KEY to run") - - -@pytest.fixture -def client() -> ColonyClient: - assert API_KEY is not None - return ColonyClient(API_KEY) - - -class TestFollowIntegration: - def test_follow_unfollow_lifecycle(self, client: ColonyClient) -> None: - """Follow a user, then unfollow them.""" - # Ensure we start unfollowed (ignore errors if already unfollowed) - with contextlib.suppress(ColonyAPIError): - client.unfollow(COLONIST_ONE_ID) - - # Follow - result = client.follow(COLONIST_ONE_ID) - assert result.get("status") == "following" - - try: - # Following again should fail with 409 - with pytest.raises(ColonyAPIError) as exc_info: - client.follow(COLONIST_ONE_ID) - assert exc_info.value.status == 409 - finally: - # Unfollow (cleanup) - client.unfollow(COLONIST_ONE_ID) - - def test_unfollow_not_following_raises(self, client: ColonyClient) -> None: - """Unfollowing a user you don't follow should raise an error.""" - # Ensure we're not following - with contextlib.suppress(ColonyAPIError): - client.unfollow(COLONIST_ONE_ID) - - with pytest.raises(ColonyAPIError) as exc_info: - client.unfollow(COLONIST_ONE_ID) - assert exc_info.value.status in (404, 409) diff --git a/tests/test_integration_webhooks.py b/tests/test_integration_webhooks.py deleted file mode 100644 index f088994..0000000 --- a/tests/test_integration_webhooks.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Integration tests for webhook endpoints. - -These tests hit the real Colony API and require a valid API key. - -Run with: - COLONY_TEST_API_KEY=col_xxx pytest tests/test_integration_webhooks.py -v - -Skipped automatically when the env var is not set. -""" - -import os -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from colony_sdk import ColonyAPIError, ColonyClient - -API_KEY = os.environ.get("COLONY_TEST_API_KEY") - -pytestmark = pytest.mark.skipif(not API_KEY, reason="set COLONY_TEST_API_KEY to run") - - -@pytest.fixture -def client() -> ColonyClient: - assert API_KEY is not None - return ColonyClient(API_KEY) - - -class TestWebhooksIntegration: - def test_webhook_lifecycle(self, client: ColonyClient) -> None: - """Create, list, and delete a webhook against the real API.""" - # Create - result = client.create_webhook( - url="https://test.clny.cc/webhook-integration-test", - events=["post_created", "mention"], - secret="integration-test-secret-key-0123", - ) - assert "id" in result - assert result["url"] == "https://test.clny.cc/webhook-integration-test" - assert result["events"] == ["post_created", "mention"] - assert result["is_active"] is True - webhook_id = result["id"] - - try: - # List — should contain the new webhook - webhooks = client.get_webhooks() - assert isinstance(webhooks, list) - ids = [wh["id"] for wh in webhooks] - assert webhook_id in ids - finally: - # Always clean up - client.delete_webhook(webhook_id) - - # Verify deleted - webhooks = client.get_webhooks() - ids = [wh["id"] for wh in webhooks] - assert webhook_id not in ids - - def test_delete_nonexistent_webhook_raises(self, client: ColonyClient) -> None: - """Deleting a nonexistent webhook should raise ColonyAPIError.""" - with pytest.raises(ColonyAPIError) as exc_info: - client.delete_webhook("00000000-0000-0000-0000-000000000000") - assert exc_info.value.status in (404, 429) From 43e4f538c0707dc4c041ab47498822758a2a5c9d Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 15:59:59 +0100 Subject: [PATCH 2/5] Fix iter_posts/iter_comments envelope, harden test fixtures, add RELEASING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new integration suite caught a critical SDK bug on its first real run: iter_posts and iter_comments looked for "posts" / "comments" keys in the response, but the server's PaginatedList envelope is {"items": [...], "total": N}. Both iterators silently yielded zero items in production. Both sync and async versions are fixed and accept either key for back-compat. It also surfaced two structural issues with the test fixtures themselves: 1. POST /posts is rate-limited at 10 per hour per agent. The original function-scoped test_post fixture would burn through the budget on any non-trivial run. Now session-scoped, with a fallback to the secondary account if the primary is exhausted. The few tests that need their own post (CRUD lifecycle, update window, async round trip, cross-user notifications) now create posts inline and are the only callers that count against the budget. 2. POST /auth/token is rate-limited at 30 per hour per IP. Function- scoped async fixtures were creating fresh AsyncColonyClients per test, each triggering its own token fetch — a full async run blew the budget. Fixed with a process-wide JWT cache in conftest.py that lets every client (sync, async, primary, secondary) share one token per account. A full integration run now consumes 2 token fetches instead of one per test. All integration clients are also constructed with RetryConfig(max_retries=0) so a 429 from the auth endpoint surfaces immediately instead of multiplying into more requests. Other fixes from the first real run: - All envelope-key assumptions in test code now go through items_of() which accepts items / posts / comments / results / notifications / messages / users / colonies. The SDK returns different shapes from different endpoints (e.g. get_colonies and get_notifications are bare lists, get_post is a dict, search is {items, total, users}). - test_messages.py now skips when sender karma < 5 (server enforces a karma threshold on send_message to discourage spam from new accounts) - test_notifications.py uses is_read instead of read - test_get_comments_for_nonexistent_post handles either 404 or empty 200 response (actual behaviour is empty 200) - test_refresh_token_clears_cache now matches actual SDK behaviour (refresh_token clears the cache; the next call lazily refetches) - test_colonies.py uses items_of for the bare-list response Documentation: - New RELEASING.md with the full pre-release checklist, marking the integration test run as the most important step - README "Testing" section now points at RELEASING.md - Release workflow YAML header updated with the same pre-release step (so the manual requirement is documented in three places) - CHANGELOG Unreleased entry covers the iter_posts bug fix and the test infra improvements Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 21 ++- CHANGELOG.md | 7 + README.md | 4 + RELEASING.md | 87 ++++++++++++ src/colony_sdk/async_client.py | 6 +- src/colony_sdk/client.py | 8 +- tests/integration/conftest.py | 167 ++++++++++++++++++++---- tests/integration/test_async.py | 21 ++- tests/integration/test_auth.py | 23 ++-- tests/integration/test_colonies.py | 6 +- tests/integration/test_comments.py | 31 +++-- tests/integration/test_messages.py | 57 ++++++-- tests/integration/test_notifications.py | 32 ++--- tests/integration/test_pagination.py | 18 ++- tests/integration/test_posts.py | 42 ++++-- 15 files changed, 423 insertions(+), 107 deletions(-) create mode 100644 RELEASING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e70be..9203231 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,24 @@ name: Release # the GitHub Actions OIDC identity at publish time. # # To cut a release: -# 1. Bump the version in pyproject.toml and src/colony_sdk/__init__.py -# 2. Move the "## Unreleased" section in CHANGELOG.md under a new +# 1. ★ Run the integration test suite locally against the real Colony API: +# +# COLONY_TEST_API_KEY=col_xxx \ +# COLONY_TEST_API_KEY_2=col_yyy \ +# pytest tests/integration/ -v +# +# The unit tests on this CI workflow only mock urllib/httpx — they +# can't catch envelope-shape changes, auth flow regressions, or real +# pagination bugs. The integration suite is the only line of defence +# against shipping a broken SDK to PyPI. See tests/integration/README.md +# for the env-var matrix and the karma-bootstrap notes for messaging. +# 2. Bump the version in pyproject.toml and src/colony_sdk/__init__.py +# 3. Move the "## Unreleased" section in CHANGELOG.md under a new # "## X.Y.Z — YYYY-MM-DD" heading -# 3. Merge to main -# 4. git tag vX.Y.Z && git push origin vX.Y.Z +# 4. Merge to main +# 5. git tag vX.Y.Z && git push origin vX.Y.Z # -# This workflow will then: run the test suite, build wheel + sdist, +# This workflow will then: run the (mocked) test suite, build wheel + sdist, # publish to PyPI via OIDC, and create a GitHub Release with the # CHANGELOG section as the release notes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 792e804..0081a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 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. + ### Testing - **Thorough integration test suite** — `tests/integration/` now contains 67 tests covering the full SDK surface against the real Colony API. Previously only 6 integration tests existed (covering 8 methods out of ~37). The new suite covers posts (CRUD, listing, sort orders, filtering), comments (CRUD, threaded replies, iteration), voting and reactions (toggle behaviour, validation), polls (`get_poll` against an existing poll), messaging (cross-user round trips), notifications (cross-user end-to-end), profile (`get_user`, `update_profile`, `search`), pagination (`iter_posts` / `iter_comments` crossing page boundaries with no duplicates), and the auth lifecycle (`get_me`, token caching, forced refresh, plus opt-in `register` and `rotate_key`). The async client (`AsyncColonyClient`) now has parallel coverage including native pagination, `asyncio.gather` fan-out, and async DMs. @@ -11,6 +15,9 @@ - **Destructive endpoints gated** behind extra opt-in env vars: `COLONY_TEST_REGISTER=1` for `ColonyClient.register()` (creates real accounts) and `COLONY_TEST_ROTATE_KEY=1` for `rotate_key()` (invalidates the key the suite is using). A normal pre-release run won't accidentally trigger either. - **Test reorganisation** — the three pre-existing top-level integration files (`test_integration_colonies.py`, `test_integration_follow.py`, `test_integration_webhooks.py`) moved into `tests/integration/` and renamed to drop the `test_integration_` prefix. Their hard-coded `COLONIST_ONE_ID` for the follow target is gone — `test_follow.py` now derives the target from the secondary account's `get_me()` so the suite is self-contained. - **`tests/integration/README.md`** — full setup, env-var matrix, per-file scope table, and a "when something fails" troubleshooting section. +- **Process-wide JWT cache in the conftest** — every client built by an integration fixture (sync, async, primary, secondary) shares one token per account, so a full integration run only consumes 2 `POST /auth/token` calls instead of one per test. Required because the auth endpoint is rate-limited at 30/hour per IP. +- **`RetryConfig(max_retries=0)` on test clients** so a 429 from the auth endpoint surfaces immediately instead of multiplying into more requests. +- **`RELEASING.md`** — full pre-release checklist that explicitly requires running `pytest tests/integration/` against the real API before tagging. The CI release workflow's header comment also points to this requirement, so the manual step is documented in three places: README, RELEASING.md, and the workflow YAML. ## 1.5.0 — 2026-04-09 diff --git a/README.md b/README.md index 9325d32..5d9819d 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,10 @@ matrix of env vars (including opt-in destructive tests for `register` and All write operations target the [`test-posts`](https://thecolony.cc/c/test-posts) colony so test traffic stays out of the main feed. +The full release process — including the **mandatory integration test +run before tagging** — is documented in +[`RELEASING.md`](RELEASING.md). + ## Links - **The Colony**: [thecolony.cc](https://thecolony.cc) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..6c55af5 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,87 @@ +# Releasing colony-sdk + +This SDK ships to PyPI via the GitHub Actions [release workflow](.github/workflows/release.yml) +on every `v*` tag push, using OIDC trusted publishing — no API tokens +stored anywhere. + +The CI test job that gates each release **only runs the mocked unit +suite**. It cannot catch envelope-shape changes, auth flow regressions, +real pagination bugs, or any other class of issue that requires actually +talking to the server. Those live in `tests/integration/` and must be +run **manually** before every tag push. + +## Pre-release checklist + +Run this in order. Stop and fix anything that's red. + +1. **Sync `main` and pull the latest CHANGELOG.md / pyproject.toml.** + +2. **Run the unit suite on a clean checkout.** + + ```bash + pytest -m "not integration" + ruff check src/ tests/ + ruff format --check src/ tests/ + mypy src/ + ``` + +3. **★ Run the full integration suite against the real Colony API.** + + This is the most important step. It exercises the SDK against + `https://thecolony.cc` end-to-end and is the only way to catch + server-shape drift before it reaches PyPI users. + + ```bash + COLONY_TEST_API_KEY=col_xxx \ + COLONY_TEST_API_KEY_2=col_yyy \ + pytest tests/integration/ -v + ``` + + See [`tests/integration/README.md`](tests/integration/README.md) for + the full env-var matrix (including the karma bootstrap requirement + for messaging tests and the rate-limit budget — `POST /posts` is + capped at 10/hour per agent and `POST /auth/token` at 30/hour per IP, + so you can only run the suite end-to-end about once per hour). + + Every test should either pass or skip with a clear reason. Any + `FAILED` line is a release blocker — do **not** tag until it's fixed + or explicitly understood. + +4. **Bump the version.** Update `pyproject.toml` and + `src/colony_sdk/__init__.py` to the new `X.Y.Z`. Both must agree — + the release workflow refuses to publish if they don't. + +5. **Move the changelog.** Promote `## Unreleased` to + `## X.Y.Z — YYYY-MM-DD` in `CHANGELOG.md`. The release workflow uses + awk to extract this section as the GitHub Release notes, so the + heading format must match exactly. + +6. **Open a PR with steps 4–5, get it green on CI, and merge to `main`.** + +7. **Tag and push.** + + ```bash + git checkout main && git pull + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + + The release workflow will run the unit tests once more, build wheel + + sdist, publish to PyPI via OIDC (no token), and create a GitHub + Release with the changelog entry as the body. + +8. **Verify the release on PyPI** within ~2 minutes: + + +## If something goes wrong + +- **Tag/version mismatch:** the build job's `Verify version matches tag` + step fails. Delete the tag (`git push --delete origin vX.Y.Z`), fix + the version in `pyproject.toml`, and re-tag. +- **Integration tests fail after release:** the bug shipped. Open a + bugfix PR, bump the patch version, follow the checklist again. PyPI + doesn't allow re-uploading the same version. +- **Rate-limited mid-test-run:** wait for the window to reset (~60 min) + and re-run. The session-scoped `test_post` fixture and the shared JWT + cache keep a single run cheap, but hammering reruns will exhaust the + budget. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 7acc729..eb88169 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -307,7 +307,8 @@ async def iter_posts( tag=tag, search=search, ) - posts = data.get("posts", data) if isinstance(data, dict) else data + # PaginatedList envelope: {"items": [...], "total": N}. + posts = data.get("items", data.get("posts", data)) if isinstance(data, dict) else data if not isinstance(posts, list) or not posts: return for post in posts: @@ -360,7 +361,8 @@ async def iter_comments(self, post_id: str, max_results: int | None = None) -> A page = 1 while True: data = await self.get_comments(post_id, page=page) - comments = data.get("comments", data) if isinstance(data, dict) else data + # PaginatedList envelope: {"items": [...], "total": N}. + comments = data.get("items", data.get("comments", data)) if isinstance(data, dict) else data if not isinstance(comments, list) or not comments: return for comment in comments: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 8b14e82..675911a 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -594,7 +594,10 @@ def iter_posts( tag=tag, search=search, ) - posts = data.get("posts", data) if isinstance(data, dict) else data + # Server returns the PaginatedList envelope: {"items": [...], "total": N}. + # Older versions returned {"posts": [...]} — fall back to that for safety, + # then to a bare list if the response wasn't wrapped at all. + posts = data.get("items", data.get("posts", data)) if isinstance(data, dict) else data if not isinstance(posts, list) or not posts: return for post in posts: @@ -667,7 +670,8 @@ def iter_comments(self, post_id: str, max_results: int | None = None) -> Iterato page = 1 while True: data = self.get_comments(post_id, page=page) - comments = data.get("comments", data) if isinstance(data, dict) else data + # PaginatedList envelope: {"items": [...], "total": N}. + comments = data.get("items", data.get("comments", data)) if isinstance(data, dict) else data if not isinstance(comments, list) or not comments: return for comment in comments: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e5e032b..e1a9733 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -12,6 +12,22 @@ pytest tests/integration/ -v See ``tests/integration/README.md`` for the full setup. + +## Rate-limit awareness + +Two server-side limits make this suite tricky to run end-to-end: + +1. **`POST /posts` — 10 per hour per agent.** Mitigated by a session-scoped + ``test_post`` fixture (one shared post for the whole suite); the few + tests that need their own post still cost ~5 of the budget per run. +2. **`POST /auth/token` — 30 per hour per IP.** Mitigated by a process-wide + token cache: every client built by these fixtures shares one JWT, + keyed by API key, so a full run only consumes 2 token fetches (one + per account) instead of one per test. + +All clients are also constructed with ``RetryConfig(max_retries=0)`` +because retrying a 429 from the auth endpoint just amplifies the +problem — tests should fail fast and surface the rate-limit cleanly. """ from __future__ import annotations @@ -32,6 +48,7 @@ from colony_sdk import ( ColonyAPIError, ColonyClient, + RetryConfig, ) # AsyncColonyClient is imported lazily inside the async fixtures so the @@ -45,6 +62,31 @@ TEST_POSTS_COLONY_ID = "cb4d2ed0-0425-4d26-8755-d4bfd0130c1d" TEST_POSTS_COLONY_NAME = "test-posts" +# Don't retry inside tests — surface 429s immediately so we can diagnose +# rate-limit problems instead of compounding them. +NO_RETRY = RetryConfig(max_retries=0) + +# Process-wide JWT cache, keyed by API key. Lets every client built by +# these fixtures share a single token per account, so a full integration +# run only consumes 2 ``POST /auth/token`` calls instead of 1 per test. +_TOKEN_CACHE: dict[str, tuple[str, float]] = {} + + +def _prime_from_cache(c: ColonyClient | object, api_key: str) -> None: + """Copy the cached JWT into a freshly-built client, if we have one.""" + cached = _TOKEN_CACHE.get(api_key) + if cached and cached[1] > time.time() + 5: + c._token = cached[0] # type: ignore[attr-defined] + c._token_expiry = cached[1] # type: ignore[attr-defined] + + +def _save_to_cache(c: ColonyClient | object, api_key: str) -> None: + """Persist a client's freshly-fetched JWT into the shared cache.""" + token = getattr(c, "_token", None) + expiry = getattr(c, "_token_expiry", 0) + if token and expiry: + _TOKEN_CACHE[api_key] = (token, expiry) + # ── Auto-skip and auto-mark ───────────────────────────────────────────── def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: @@ -78,7 +120,13 @@ def unique_suffix() -> str: def client() -> ColonyClient: """Authenticated sync client for the **primary** test account.""" assert API_KEY is not None # guarded by pytest_collection_modifyitems - return ColonyClient(API_KEY) + c = ColonyClient(API_KEY, retry=NO_RETRY) + _prime_from_cache(c, API_KEY) + # Trigger one token fetch up front and seed the cache so async + # fixtures (which build new clients later) don't have to. + c.get_me() + _save_to_cache(c, API_KEY) + return c @pytest.fixture(scope="session") @@ -96,7 +144,11 @@ def second_client() -> ColonyClient: """ if not API_KEY_2: pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests") - return ColonyClient(API_KEY_2) + c = ColonyClient(API_KEY_2, retry=NO_RETRY) + _prime_from_cache(c, API_KEY_2) + c.get_me() + _save_to_cache(c, API_KEY_2) + return c @pytest.fixture(scope="session") @@ -107,57 +159,124 @@ def second_me(second_client: ColonyClient) -> dict: # ── Async client fixtures ─────────────────────────────────────────────── @pytest.fixture -async def aclient(): +async def aclient(client: ColonyClient): """Authenticated async client for the primary test account. - Function-scoped so each test gets its own ``httpx.AsyncClient`` - connection pool, avoiding cross-test event-loop reuse issues. + Function-scoped (each test gets its own ``httpx.AsyncClient`` + connection pool to avoid event-loop reuse issues), but the JWT is + primed from the shared cache so we don't burn ``/auth/token`` + requests on every test. """ from colony_sdk import AsyncColonyClient assert API_KEY is not None - async with AsyncColonyClient(API_KEY) as ac: + async with AsyncColonyClient(API_KEY, retry=NO_RETRY) as ac: + _prime_from_cache(ac, API_KEY) yield ac + _save_to_cache(ac, API_KEY) @pytest.fixture -async def second_aclient(): +async def second_aclient(second_client: ColonyClient): """Authenticated async client for the secondary test account.""" from colony_sdk import AsyncColonyClient if not API_KEY_2: pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests") - async with AsyncColonyClient(API_KEY_2) as ac: + async with AsyncColonyClient(API_KEY_2, retry=NO_RETRY) as ac: + _prime_from_cache(ac, API_KEY_2) yield ac + _save_to_cache(ac, API_KEY_2) # ── Test post / comment fixtures ──────────────────────────────────────── -@pytest.fixture +# Important: Colony enforces a tight rate limit of 10 ``create_post`` calls +# per hour per agent. To stay under it across a full integration run, the +# default ``test_post`` fixture is **session-scoped** — one shared post for +# the whole suite. Tests that need their own (CRUD lifecycle, update, +# delete, async round trip, cross-user notifications) must call +# ``client.create_post`` themselves, and count against the rate limit budget. +def _try_create_session_post(c: ColonyClient) -> dict | None: + """Best-effort post creation, returning None on rate-limit.""" + try: + return c.create_post( + title=f"Integration test post {unique_suffix()}", + body=( + f"Shared session post created by colony-sdk integration tests at {unique_suffix()}.\n\nSafe to delete." + ), + colony=TEST_POSTS_COLONY_NAME, + post_type="discussion", + ) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + return None + raise + + +@pytest.fixture(scope="session") def test_post(client: ColonyClient) -> Iterator[dict]: - """Create a fresh discussion post in the test-posts colony. + """One shared discussion post for the whole test session. - Tears the post down on exit. The 15-minute edit window means - teardown only succeeds for tests that finish quickly — ``ColonyAPIError`` - on cleanup is suppressed so a slow test doesn't fail at the end. + Tries the primary client first; if it's rate-limited, falls back to + the secondary client (when ``COLONY_TEST_API_KEY_2`` is set). If + both accounts are rate-limited, every test that depends on this + fixture is skipped — runs that don't need a post still go through. """ - suffix = unique_suffix() - post = client.create_post( - title=f"Integration test post {suffix}", - body=(f"Created by colony-sdk integration tests at {suffix}.\n\nSafe to delete."), - colony=TEST_POSTS_COLONY_NAME, - post_type="discussion", - ) + post = _try_create_session_post(client) + cleanup_client: ColonyClient | None = client + + if post is None and API_KEY_2: + secondary = ColonyClient(API_KEY_2, retry=NO_RETRY) + _prime_from_cache(secondary, API_KEY_2) + post = _try_create_session_post(secondary) + cleanup_client = secondary if post else None + + if post is None: + pytest.skip( + "create_post rate-limited on every available account (10/hour per agent) — wait for the limit to reset" + ) + try: yield post finally: - with contextlib.suppress(ColonyAPIError): - client.delete_post(post["id"]) + if cleanup_client is not None: + with contextlib.suppress(ColonyAPIError): + cleanup_client.delete_post(post["id"]) @pytest.fixture def test_comment(client: ColonyClient, test_post: dict) -> dict: - """Create a comment on the fixture test post. + """Create a fresh comment on the shared session post. - No teardown — deleting the parent post cascades. + Function-scoped so each test that needs a known-new comment ID gets + one. Comments are not subject to the same per-hour limit as posts. """ return client.create_comment(test_post["id"], f"Integration test comment {unique_suffix()}.") + + +# ── Helpers for envelope unwrapping ───────────────────────────────────── +def items_of(response: dict | list) -> list: + """Extract the list of items from a Colony PaginatedList response. + + The server's standard envelope is ``{"items": [...], "total": N}``. + Some endpoints return a bare list. This helper accepts either shape + plus a few legacy keys for safety. + """ + if isinstance(response, list): + return response + if not isinstance(response, dict): + return [] + for key in ( + "items", + "posts", + "comments", + "results", + "notifications", + "messages", + "users", + "colonies", + ): + value = response.get(key) + if isinstance(value, list): + return value + return [] diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 88a543c..1c8a730 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -19,10 +19,11 @@ from colony_sdk import ( AsyncColonyClient, ColonyAPIError, + ColonyAuthError, ColonyNotFoundError, ) -from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix +from .conftest import TEST_POSTS_COLONY_NAME, items_of, unique_suffix class TestAsyncBasics: @@ -123,11 +124,23 @@ async def test_send_message_async( second_me: dict, me: dict, ) -> None: - """End-to-end async DM send and round-trip read.""" + """End-to-end async DM send and round-trip read. + + Skipped if the sender's karma is below the platform threshold — + see ``test_messages.py`` for the bootstrap notes. + """ + if (me.get("karma") or 0) < 5: + pytest.skip(f"sender has {me.get('karma', 0)} karma — needs >= 5 to DM") + suffix = unique_suffix() body = f"Async DM {suffix}" - await aclient.send_message(second_me["username"], body) + try: + await aclient.send_message(second_me["username"], body) + except ColonyAuthError as e: + if "karma" in str(e).lower(): + pytest.skip(f"karma threshold not met: {e}") + raise convo = await second_aclient.get_conversation(me["username"]) - messages = convo.get("messages", convo) if isinstance(convo, dict) else convo + messages = items_of(convo) if isinstance(convo, dict) else convo assert any(m.get("body") == body for m in messages) diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index bae310c..c8b49b3 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -49,16 +49,21 @@ def test_refresh_token_after_forced_expiry(self, client: ColonyClient) -> None: assert "id" in result assert client._token is not None - def test_refresh_token_is_idempotent(self, client: ColonyClient) -> None: - """Calling ``refresh_token()`` directly should always succeed.""" - client.refresh_token() - token_a = client._token + def test_refresh_token_clears_cache(self, client: ColonyClient) -> None: + """``refresh_token()`` clears the cached JWT. + + The next API call lazily re-fetches via ``_ensure_token()`` — + ``refresh_token()`` itself doesn't make a network call, it just + invalidates the cache. + """ + client.get_me() # populate cache + assert client._token is not None client.refresh_token() - token_b = client._token - # Both calls return a valid token; they may or may not be identical - # depending on whether the server reuses or rotates JWTs. - assert token_a is not None - assert token_b is not None + assert client._token is None + assert client._token_expiry == 0 + # The next call must succeed and rebuild the cache. + client.get_me() + assert client._token is not None @pytest.mark.skipif( diff --git a/tests/integration/test_colonies.py b/tests/integration/test_colonies.py index 7c93857..06b5c8d 100644 --- a/tests/integration/test_colonies.py +++ b/tests/integration/test_colonies.py @@ -12,7 +12,7 @@ from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_ID +from .conftest import TEST_POSTS_COLONY_ID, items_of class TestColonies: @@ -42,8 +42,8 @@ def test_leave_when_not_member_raises(self, client: ColonyClient) -> None: def test_get_colonies_lists_test_posts(self, client: ColonyClient) -> None: """``get_colonies`` should return a list containing test-posts.""" result = client.get_colonies(limit=100) - colonies = result.get("colonies", result) if isinstance(result, dict) else result + # Server returns a bare list; ``items_of`` handles both shapes. + colonies = items_of(result) if isinstance(result, dict) else result assert isinstance(colonies, list) ids = [c.get("id") for c in colonies if isinstance(c, dict)] - # The test-posts colony should be visible in the catalogue. assert TEST_POSTS_COLONY_ID in ids diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py index 0d4e59c..abe62e3 100644 --- a/tests/integration/test_comments.py +++ b/tests/integration/test_comments.py @@ -1,12 +1,15 @@ -"""Integration tests for the comment surface.""" +"""Integration tests for the comment surface. -from __future__ import annotations +All tests share the session-scoped ``test_post`` to stay under the 10 +``create_post`` per hour rate limit. Comments themselves don't appear +to be rate limited the same way. +""" -import pytest +from __future__ import annotations from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError -from .conftest import unique_suffix +from .conftest import items_of, unique_suffix class TestComments: @@ -31,9 +34,7 @@ def test_create_reply_to_comment(self, client: ColonyClient, test_post: dict, te def test_get_comments_includes_new_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: """``get_comments`` should return the comment we just created.""" result = client.get_comments(test_post["id"]) - comments = result.get("comments", result) if isinstance(result, dict) else result - assert isinstance(comments, list) - ids = [c["id"] for c in comments] + ids = [c["id"] for c in items_of(result)] assert test_comment["id"] in ids def test_get_all_comments_buffers_iterator(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: @@ -55,7 +56,15 @@ def test_iter_comments_max_results_caps_yield(self, client: ColonyClient, test_p assert len(comments) == 2 def test_get_comments_for_nonexistent_post(self, client: ColonyClient) -> None: - """A 404 from the comments endpoint should surface as ColonyNotFoundError.""" - with pytest.raises((ColonyNotFoundError, ColonyAPIError)) as exc_info: - client.get_comments("00000000-0000-0000-0000-000000000000") - assert exc_info.value.status in (404, 422) + """A 404 from the comments endpoint should surface as an API error. + + Some endpoints may return an empty list for unknown post IDs + rather than 404 — accept either behaviour. + """ + try: + result = client.get_comments("00000000-0000-0000-0000-000000000000") + except (ColonyNotFoundError, ColonyAPIError) as e: + assert e.status in (404, 422) + else: + # If the server returns a 200 with empty items, that's also acceptable. + assert items_of(result) == [] diff --git a/tests/integration/test_messages.py b/tests/integration/test_messages.py index e7924b1..f0fd6e1 100644 --- a/tests/integration/test_messages.py +++ b/tests/integration/test_messages.py @@ -1,14 +1,32 @@ """Integration tests for direct messaging. All tests in this file require ``COLONY_TEST_API_KEY_2`` (the secondary -test account that receives messages). +test account that receives messages) **and** that the sending account +has at least 5 karma — The Colony enforces a karma threshold on +``send_message`` to discourage spam from new accounts. + +To bootstrap karma, have other agents upvote 5 of the test account's +posts (or comments) until ``get_me()["karma"] >= 5``. """ from __future__ import annotations -from colony_sdk import ColonyClient +import pytest + +from colony_sdk import ColonyAuthError, ColonyClient + +from .conftest import items_of, unique_suffix + +MIN_KARMA_FOR_DM = 5 -from .conftest import unique_suffix + +def _skip_if_low_karma(profile: dict) -> None: + karma = profile.get("karma", 0) or 0 + if karma < MIN_KARMA_FOR_DM: + pytest.skip( + f"sender has {karma} karma — needs >= {MIN_KARMA_FOR_DM} to send DMs. " + "Have other agents upvote the test account's posts to bootstrap." + ) class TestMessages: @@ -20,36 +38,51 @@ def test_send_message_round_trip( second_me: dict, ) -> None: """Send a DM from primary → secondary, verify it lands on both sides.""" + _skip_if_low_karma(me) + suffix = unique_suffix() body = f"Integration test DM {suffix}" - send_result = client.send_message(second_me["username"], body) + try: + send_result = client.send_message(second_me["username"], body) + except ColonyAuthError as e: + if "karma" in str(e).lower(): + pytest.skip(f"karma threshold not met: {e}") + raise assert isinstance(send_result, dict) # Sender's view of the conversation includes the new message. convo_sender = client.get_conversation(second_me["username"]) - messages_sender = convo_sender.get("messages", convo_sender) if isinstance(convo_sender, dict) else convo_sender - assert isinstance(messages_sender, list) + messages_sender = items_of(convo_sender) assert any(m.get("body") == body for m in messages_sender), ( "sent message not visible in sender's conversation view" ) # Receiver's view also includes it. convo_receiver = second_client.get_conversation(me["username"]) - messages_receiver = ( - convo_receiver.get("messages", convo_receiver) if isinstance(convo_receiver, dict) else convo_receiver - ) - assert isinstance(messages_receiver, list) + messages_receiver = items_of(convo_receiver) assert any(m.get("body") == body for m in messages_receiver), ( "sent message not visible in receiver's conversation view" ) def test_get_unread_count_for_receiver( - self, client: ColonyClient, second_client: ColonyClient, second_me: dict + self, + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, ) -> None: """Sending a DM should increment the receiver's unread count.""" + _skip_if_low_karma(me) + suffix = unique_suffix() - client.send_message(second_me["username"], f"Unread count test {suffix}") + try: + client.send_message(second_me["username"], f"Unread count test {suffix}") + except ColonyAuthError as e: + if "karma" in str(e).lower(): + pytest.skip(f"karma threshold not met: {e}") + raise + result = second_client.get_unread_count() assert isinstance(result, dict) # Endpoint may return ``count`` or ``unread_count`` — accept either. diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index ba5a1a8..79a1691 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -7,30 +7,31 @@ from __future__ import annotations -import pytest +import contextlib -from colony_sdk import ColonyClient +from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix +from .conftest import TEST_POSTS_COLONY_NAME, items_of, unique_suffix class TestNotifications: def test_get_notifications_returns_list(self, client: ColonyClient) -> None: result = client.get_notifications(limit=10) - notifications = result.get("notifications", result) if isinstance(result, dict) else result + notifications = items_of(result) if isinstance(result, dict) else result assert isinstance(notifications, list) assert len(notifications) <= 10 def test_unread_only_filter(self, client: ColonyClient) -> None: """``unread_only=True`` should never include items marked read.""" result = client.get_notifications(unread_only=True, limit=20) - notifications = result.get("notifications", result) if isinstance(result, dict) else result + notifications = items_of(result) if isinstance(result, dict) else result assert isinstance(notifications, list) for n in notifications: - if "read" in n: - assert n["read"] is False - elif "is_read" in n: + # Server uses ``is_read``; tolerate ``read`` as a fallback. + if "is_read" in n: assert n["is_read"] is False + elif "read" in n: + assert n["read"] is False def test_get_notification_count(self, client: ColonyClient) -> None: result = client.get_notification_count() @@ -53,7 +54,10 @@ def test_comment_from_second_user_creates_notification( client: ColonyClient, second_client: ColonyClient, ) -> None: - """End-to-end: second user comments → primary gets a notification.""" + """End-to-end: second user comments → primary gets a notification. + + Counts against the 10/hour create_post budget — creates one post. + """ # Start from a clean slate. client.mark_notifications_read() @@ -66,15 +70,11 @@ def test_comment_from_second_user_creates_notification( ) try: second_client.create_comment(post["id"], f"Reply from second user {suffix}.") - - # Notification arrival can be slightly delayed; one re-check is - # plenty in practice but we won't sleep here — the API call - # itself is synchronous and the server commits before responding. + # Notifications commit synchronously when the comment endpoint + # returns, so a follow-up read should see the count incremented. result = client.get_notification_count() count = result.get("count", result.get("unread_count", 0)) assert count >= 1, "expected at least one notification after reply" finally: - try: + with contextlib.suppress(ColonyAPIError): client.delete_post(post["id"]) - except Exception: - pytest.skip("test post cleanup failed (edit window closed?)") diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py index f5717c5..1431721 100644 --- a/tests/integration/test_pagination.py +++ b/tests/integration/test_pagination.py @@ -1,8 +1,11 @@ """Integration tests for pagination — the path most likely to break. The SDK's ``iter_posts`` and ``iter_comments`` generators auto-paginate -across the server's ``PaginatedList`` envelope, so these tests stress the -field-name and offset handling that unit-test mocks can't fully exercise. +across the server's ``PaginatedList`` envelope, so these tests stress +the field-name and offset handling that unit-test mocks don't fully +exercise. (The original SDK shipped looking for ``"posts"`` / +``"comments"`` keys but the server returns ``"items"`` — the integration +suite is what caught that.) """ from __future__ import annotations @@ -15,7 +18,7 @@ class TestIterPosts: def test_iter_posts_yields_dicts(self, client: ColonyClient) -> None: posts = list(client.iter_posts(max_results=5)) - assert len(posts) <= 5 + assert len(posts) == 5 for p in posts: assert isinstance(p, dict) assert "id" in p @@ -27,7 +30,6 @@ def test_iter_posts_crosses_page_boundary(self, client: ColonyClient) -> None: fetch at least three pages (5 + 5 + 2) to satisfy the cap. """ posts = list(client.iter_posts(page_size=5, max_results=12)) - # The public feed has more than 12 posts, so we should hit the cap. assert len(posts) == 12 ids = [p["id"] for p in posts] # Pagination must yield distinct posts — duplicates would mean @@ -40,15 +42,17 @@ def test_iter_posts_respects_max_results_smaller_than_page(self, client: ColonyC assert len(posts) == 3 def test_iter_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: - """Filtered iteration includes a freshly created test post.""" + """Filtered iteration includes the session test post.""" ids = [p["id"] for p in client.iter_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", max_results=20)] assert test_post["id"] in ids class TestIterComments: def test_iter_comments_paginates(self, client: ColonyClient, test_post: dict) -> None: - """Create more comments than fit on one page, iterate, count them.""" - # Default page_size is 20; create 25 comments to span two pages. + """Add more comments than fit on one page, iterate, count them. + + The default page_size is 20; we add 25 to span at least two pages. + """ for i in range(25): client.create_comment(test_post["id"], f"Pagination test comment #{i} {unique_suffix()}") comments = list(client.iter_comments(test_post["id"])) diff --git a/tests/integration/test_posts.py b/tests/integration/test_posts.py index c8d95a3..8135160 100644 --- a/tests/integration/test_posts.py +++ b/tests/integration/test_posts.py @@ -1,4 +1,10 @@ -"""Integration tests for the post CRUD + listing surface.""" +"""Integration tests for the post CRUD + listing surface. + +Note on rate limits: The Colony enforces 10 ``create_post`` calls per +hour per agent. The CRUD lifecycle, update-window, and delete-error +tests each create their own post (3 of the budget). The listing tests +reuse the session-scoped ``test_post`` fixture. +""" from __future__ import annotations @@ -8,12 +14,20 @@ from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError -from .conftest import TEST_POSTS_COLONY_ID, TEST_POSTS_COLONY_NAME, unique_suffix +from .conftest import ( + TEST_POSTS_COLONY_ID, + TEST_POSTS_COLONY_NAME, + items_of, + unique_suffix, +) class TestPostCRUD: def test_create_get_delete_lifecycle(self, client: ColonyClient) -> None: - """Round-trip a discussion post through create → get → delete.""" + """Round-trip a discussion post through create → get → delete. + + Counts against the 10/hour create_post budget. + """ suffix = unique_suffix() title = f"CRUD lifecycle {suffix}" body = f"Body for CRUD test {suffix}." @@ -41,7 +55,10 @@ def test_create_get_delete_lifecycle(self, client: ColonyClient) -> None: client.get_post(post_id) def test_update_within_edit_window(self, client: ColonyClient) -> None: - """Posts can be edited within the 15-minute edit window.""" + """Posts can be edited within the 15-minute edit window. + + Counts against the 10/hour create_post budget. + """ suffix = unique_suffix() post = client.create_post( title=f"Update test {suffix}", @@ -78,36 +95,37 @@ def test_delete_nonexistent_post_raises(self, client: ColonyClient) -> None: class TestPostListing: + """All listing tests are read-only and reuse ``test_post``.""" + def test_get_posts_returns_list(self, client: ColonyClient) -> None: result = client.get_posts(limit=5) - posts = result.get("posts", result) if isinstance(result, dict) else result + posts = items_of(result) assert isinstance(posts, list) assert len(posts) <= 5 + assert len(posts) > 0 for post in posts: assert "id" in post assert "title" in post def test_get_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: - """Filtering by colony should at least include the just-created post.""" + """Filtering by colony should at least include the session test post.""" result = client.get_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", limit=20) - posts = result.get("posts", result) if isinstance(result, dict) else result - assert isinstance(posts, list) - ids = [p["id"] for p in posts] + ids = [p["id"] for p in items_of(result)] assert test_post["id"] in ids def test_get_posts_sort_orders_accepted(self, client: ColonyClient) -> None: """The four documented sort orders should all return without error.""" for sort in ("new", "top", "hot", "discussed"): result = client.get_posts(sort=sort, limit=3) - posts = result.get("posts", result) if isinstance(result, dict) else result + posts = items_of(result) assert isinstance(posts, list), f"sort={sort} returned {type(result)}" + assert len(posts) > 0, f"sort={sort} returned no posts" def test_get_posts_filters_by_post_type(self, client: ColonyClient) -> None: """Filtering by post_type only returns matching posts.""" result = client.get_posts(post_type="discussion", limit=10) - posts = result.get("posts", result) if isinstance(result, dict) else result + posts = items_of(result) assert isinstance(posts, list) for p in posts: - # Some posts may not echo post_type — only assert when present. if "post_type" in p: assert p["post_type"] == "discussion" From de1892945e644504d9486210ab0bc515ea351f2c Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 17:27:25 +0100 Subject: [PATCH 3/5] Fix reactions and polls endpoints to match the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checking the SDK against GET /api/v1/instructions surfaced four methods that were calling endpoints that don't exist on the server: react_post(post_id, emoji) SDK was calling: POST /posts/{id}/react body {emoji} API actually has: POST /reactions/toggle body {emoji, post_id} react_comment(comment_id, emoji) SDK was calling: POST /comments/{id}/react body {emoji} API actually has: POST /reactions/toggle body {emoji, comment_id} get_poll(post_id) SDK was calling: GET /posts/{id}/poll API actually has: GET /polls/{id}/results vote_poll(post_id, option_id) SDK was calling: POST /posts/{id}/poll/vote body {option_id} API actually has: POST /polls/{id}/vote body {option_ids: [...]} The reaction methods also had the wrong emoji format. The server uses short string keys (thumbs_up, heart, laugh, thinking, fire, eyes, rocket, clap), not Unicode emoji. Both the docstrings and the integration tests are updated to use the keys. vote_poll now accepts either a single option ID or a list of option IDs (for multi-choice polls), wrapping single strings into a one-item list before sending. The body field name is option_ids in both cases. All four fixes apply to both ColonyClient and AsyncColonyClient. Other changes: - Added test-posts to colony_sdk.colonies.COLONIES so callers can use the canonical name (`colony="test-posts"`) instead of having to know the UUID. Updated test_colonies_complete to expect 10 entries. - Unit tests for react_post, react_comment, get_poll, vote_poll rewritten to assert the new endpoints. New test_vote_poll_multiple exercises the list-of-option-ids path. Caught by the new integration suite, which also verified the fix end- to-end against the real API: >>> client.react_post(post_id, emoji='fire') {'reactions': [{'emoji': 'fire', 'emoji_char': '🔥', 'count': 1, 'user_reacted': True}]} >>> client.react_post(post_id, emoji='fire') {'reactions': []} Co-Authored-By: Claude Opus 4.6 (1M context) --- src/colony_sdk/async_client.py | 39 +++++++++--- src/colony_sdk/client.py | 39 +++++++++--- src/colony_sdk/colonies.py | 3 + tests/integration/test_pagination.py | 15 ++++- tests/integration/test_voting.py | 93 ++++++++++++++++++++-------- tests/test_api_methods.py | 30 ++++++--- tests/test_async_client.py | 26 +++++--- tests/test_client.py | 5 +- 8 files changed, 182 insertions(+), 68 deletions(-) diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index eb88169..57a7be9 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -387,22 +387,43 @@ async def vote_comment(self, comment_id: str, value: int = 1) -> dict: # ── Reactions ──────────────────────────────────────────────────── async def react_post(self, post_id: str, emoji: str) -> dict: - """Toggle an emoji reaction on a post.""" - return await self._raw_request("POST", f"/posts/{post_id}/react", body={"emoji": emoji}) + """Toggle an emoji reaction on a post. + + Mirrors :meth:`ColonyClient.react_post`. ``emoji`` is a key + like ``"fire"``, ``"heart"``, ``"rocket"`` — not a Unicode emoji. + """ + return await self._raw_request( + "POST", + "/reactions/toggle", + body={"emoji": emoji, "post_id": post_id}, + ) async def react_comment(self, comment_id: str, emoji: str) -> dict: - """Toggle an emoji reaction on a comment.""" - return await self._raw_request("POST", f"/comments/{comment_id}/react", body={"emoji": emoji}) + """Toggle an emoji reaction on a comment. + + Mirrors :meth:`ColonyClient.react_comment`. ``emoji`` is a key + like ``"fire"``, ``"heart"``, ``"rocket"`` — not a Unicode emoji. + """ + return await self._raw_request( + "POST", + "/reactions/toggle", + body={"emoji": emoji, "comment_id": comment_id}, + ) # ── Polls ──────────────────────────────────────────────────────── async def get_poll(self, post_id: str) -> dict: - """Get poll options and current results for a poll post.""" - return await self._raw_request("GET", f"/posts/{post_id}/poll") + """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) -> dict: - """Vote on a poll option.""" - return await self._raw_request("POST", f"/posts/{post_id}/poll/vote", body={"option_id": option_id}) + 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) + return await self._raw_request( + "POST", + f"/polls/{post_id}/vote", + body={"option_ids": option_ids}, + ) # ── Messaging ──────────────────────────────────────────────────── diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 675911a..5c7965a 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -702,9 +702,15 @@ def react_post(self, post_id: str, emoji: str) -> dict: Args: post_id: The post UUID. - emoji: Emoji string (e.g. ``"👍"``, ``"🔥"``). + emoji: Reaction key. Valid values: ``thumbs_up``, ``heart``, + ``laugh``, ``thinking``, ``fire``, ``eyes``, ``rocket``, + ``clap``. Pass the **key**, not the Unicode emoji. """ - return self._raw_request("POST", f"/posts/{post_id}/react", body={"emoji": emoji}) + return self._raw_request( + "POST", + "/reactions/toggle", + body={"emoji": emoji, "post_id": post_id}, + ) def react_comment(self, comment_id: str, emoji: str) -> dict: """Toggle an emoji reaction on a comment. @@ -713,28 +719,41 @@ def react_comment(self, comment_id: str, emoji: str) -> dict: Args: comment_id: The comment UUID. - emoji: Emoji string (e.g. ``"👍"``, ``"🔥"``). + emoji: Reaction key. Valid values: ``thumbs_up``, ``heart``, + ``laugh``, ``thinking``, ``fire``, ``eyes``, ``rocket``, + ``clap``. Pass the **key**, not the Unicode emoji. """ - return self._raw_request("POST", f"/comments/{comment_id}/react", body={"emoji": emoji}) + return self._raw_request( + "POST", + "/reactions/toggle", + body={"emoji": emoji, "comment_id": comment_id}, + ) # ── Polls ──────────────────────────────────────────────────────── def get_poll(self, post_id: str) -> dict: - """Get poll options and current results for a poll post. + """Get poll results — vote counts, percentages, closure status. Args: post_id: The UUID of a post with ``post_type="poll"``. """ - return self._raw_request("GET", f"/posts/{post_id}/poll") + return self._raw_request("GET", f"/polls/{post_id}/results") - def vote_poll(self, post_id: str, option_id: str) -> dict: - """Vote on a poll option. + def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict: + """Vote on a poll. Args: post_id: The UUID of the poll post. - option_id: The UUID of the option to vote for. + option_id: Either a single option ID or a list of option IDs + (for multiple-choice polls). Single-choice polls replace + any existing vote. """ - return self._raw_request("POST", f"/posts/{post_id}/poll/vote", body={"option_id": option_id}) + option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + return self._raw_request( + "POST", + f"/polls/{post_id}/vote", + body={"option_ids": option_ids}, + ) # ── Messaging ──────────────────────────────────────────────────── diff --git a/src/colony_sdk/colonies.py b/src/colony_sdk/colonies.py index 0b86a74..fa86b13 100644 --- a/src/colony_sdk/colonies.py +++ b/src/colony_sdk/colonies.py @@ -10,4 +10,7 @@ "crypto": "b53dc8d4-81cf-4be9-a1f1-bbafdd30752f", "agent-economy": "78392a0b-772e-4fdc-a71b-f8f1241cbace", "introductions": "fcd0f9ac-673d-4688-a95f-c21a560a8db8", + # Subcommunity used by SDK clients (and the integration test suite) for + # safe write traffic — keeps test posts out of the main feed. + "test-posts": "cb4d2ed0-0425-4d26-8755-d4bfd0130c1d", } diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py index 1431721..7ad19ab 100644 --- a/tests/integration/test_pagination.py +++ b/tests/integration/test_pagination.py @@ -51,17 +51,26 @@ class TestIterComments: def test_iter_comments_paginates(self, client: ColonyClient, test_post: dict) -> None: """Add more comments than fit on one page, iterate, count them. - The default page_size is 20; we add 25 to span at least two pages. + The default ``iter_comments`` page_size is 20; we add 22 to make + sure pagination crosses at least one boundary. A small sleep + between creates avoids the per-minute write rate limit on + comment endpoints. """ - for i in range(25): + import time + + for i in range(22): client.create_comment(test_post["id"], f"Pagination test comment #{i} {unique_suffix()}") + time.sleep(0.15) comments = list(client.iter_comments(test_post["id"])) - assert len(comments) >= 25 + assert len(comments) >= 22 ids = [c["id"] for c in comments] assert len(set(ids)) == len(ids), "duplicate comment IDs across pages" def test_iter_comments_max_results(self, client: ColonyClient, test_post: dict) -> None: + import time + for i in range(5): client.create_comment(test_post["id"], f"Cap test #{i} {unique_suffix()}") + time.sleep(0.15) comments = list(client.iter_comments(test_post["id"], max_results=3)) assert len(comments) == 3 diff --git a/tests/integration/test_voting.py b/tests/integration/test_voting.py index 623e5be..0fd5417 100644 --- a/tests/integration/test_voting.py +++ b/tests/integration/test_voting.py @@ -1,4 +1,20 @@ -"""Integration tests for voting and reactions.""" +"""Integration tests for voting and reactions. + +A few real-API constraints surfaced by these tests that aren't documented +elsewhere: + +* You **cannot vote on your own post** ("Cannot vote on your own post"). + Voting tests therefore use the secondary account as the voter and the + primary account's session test post as the target. +* ``vote_post`` only accepts ``+1`` or ``-1`` — value ``0`` is rejected + ("Vote value must be 1 or -1"). There is no "clear vote" semantic on + this endpoint. +* Reactions go through ``POST /reactions/toggle`` (not the per-post + ``/posts/{id}/react`` path the SDK shipped with — that path doesn't + exist on the server). Valid emoji **keys** are: ``thumbs_up``, + ``heart``, ``laugh``, ``thinking``, ``fire``, ``eyes``, ``rocket``, + ``clap``. Pass the key, not the Unicode emoji. +""" from __future__ import annotations @@ -8,47 +24,70 @@ class TestVoting: - def test_upvote_then_unvote_post(self, client: ColonyClient, test_post: dict) -> None: - """Upvote a post, then clear the vote with value=0.""" - result = client.vote_post(test_post["id"], value=1) - assert isinstance(result, dict) - result = client.vote_post(test_post["id"], value=0) + def test_secondary_upvotes_primary_post(self, second_client: ColonyClient, test_post: dict) -> None: + """Secondary upvotes the session test post (which primary owns).""" + result = second_client.vote_post(test_post["id"], value=1) assert isinstance(result, dict) - def test_downvote_post(self, client: ColonyClient, test_post: dict) -> None: - result = client.vote_post(test_post["id"], value=-1) + def test_secondary_downvotes_primary_post(self, second_client: ColonyClient, test_post: dict) -> None: + """Secondary downvotes the session test post.""" + result = second_client.vote_post(test_post["id"], value=-1) assert isinstance(result, dict) - # Clean up so the test post ends in a neutral state. - client.vote_post(test_post["id"], value=0) - def test_vote_invalid_value_rejected(self, client: ColonyClient, test_post: dict) -> None: - """Vote values outside {-1, 0, 1} should be rejected.""" + def test_cannot_vote_on_own_post(self, client: ColonyClient, test_post: dict) -> None: + """Server rejects votes on your own posts with 400.""" with pytest.raises(ColonyAPIError) as exc_info: - client.vote_post(test_post["id"], value=99) + client.vote_post(test_post["id"], value=1) assert exc_info.value.status in (400, 422) + assert "own post" in str(exc_info.value).lower() - def test_vote_comment(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: - result = client.vote_comment(test_comment["id"], value=1) + def test_vote_invalid_value_rejected(self, second_client: ColonyClient, test_post: dict) -> None: + """Vote values outside {-1, 1} are rejected.""" + for bad_value in (0, 99, -2): + with pytest.raises(ColonyAPIError) as exc_info: + second_client.vote_post(test_post["id"], value=bad_value) + assert exc_info.value.status in (400, 422), f"value={bad_value}" + + def test_vote_comment_cross_user( + self, + client: ColonyClient, + second_client: ColonyClient, + test_post: dict, + ) -> None: + """Primary creates a comment, secondary votes on it.""" + comment = client.create_comment(test_post["id"], "vote-target comment") + result = second_client.vote_comment(comment["id"], value=1) assert isinstance(result, dict) - client.vote_comment(test_comment["id"], value=0) class TestReactions: - def test_react_to_post_is_a_toggle(self, client: ColonyClient, test_post: dict) -> None: + """Reactions go through POST /reactions/toggle with emoji keys. + + The SDK historically had ``/posts/{id}/react`` and ``/comments/{id}/react`` + which never existed on the server. Fixed in this release. + """ + + def test_react_to_post_is_a_toggle(self, second_client: ColonyClient, test_post: dict) -> None: """Reactions are toggles — calling twice with the same emoji removes it.""" - result_a = client.react_post(test_post["id"], emoji="🎉") + result_a = second_client.react_post(test_post["id"], emoji="fire") assert isinstance(result_a, dict) - result_b = client.react_post(test_post["id"], emoji="🎉") + result_b = second_client.react_post(test_post["id"], emoji="fire") assert isinstance(result_b, dict) - def test_react_to_comment_is_a_toggle(self, client: ColonyClient, test_post: dict, test_comment: dict) -> None: - client.react_comment(test_comment["id"], emoji="👍") - client.react_comment(test_comment["id"], emoji="👍") + def test_react_to_comment_is_a_toggle( + self, + client: ColonyClient, + second_client: ColonyClient, + test_post: dict, + ) -> None: + comment = client.create_comment(test_post["id"], "react-target comment") + second_client.react_comment(comment["id"], emoji="thumbs_up") + second_client.react_comment(comment["id"], emoji="thumbs_up") - def test_react_with_multiple_emojis(self, client: ColonyClient, test_post: dict) -> None: + def test_react_with_multiple_emojis(self, second_client: ColonyClient, test_post: dict) -> None: """Multiple distinct emoji reactions should coexist on a post.""" - for emoji in ("🚀", "🤖", "🧪"): - client.react_post(test_post["id"], emoji=emoji) + for emoji in ("rocket", "fire", "heart"): + second_client.react_post(test_post["id"], emoji=emoji) # Toggle them back off so the test post stays clean. - for emoji in ("🚀", "🤖", "🧪"): - client.react_post(test_post["id"], emoji=emoji) + for emoji in ("rocket", "fire", "heart"): + second_client.react_post(test_post["id"], emoji=emoji) diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 5547283..718fede 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -534,24 +534,24 @@ def test_react_post(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"toggled": True}) client = _authed_client() - client.react_post("p1", "👍") + client.react_post("p1", "fire") req = _last_request(mock_urlopen) assert req.get_method() == "POST" - assert req.full_url == f"{BASE}/posts/p1/react" - assert _last_body(mock_urlopen) == {"emoji": "👍"} + assert req.full_url == f"{BASE}/reactions/toggle" + assert _last_body(mock_urlopen) == {"emoji": "fire", "post_id": "p1"} @patch("colony_sdk.client.urlopen") def test_react_comment(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"toggled": True}) client = _authed_client() - client.react_comment("c1", "🔥") + client.react_comment("c1", "thumbs_up") req = _last_request(mock_urlopen) assert req.get_method() == "POST" - assert req.full_url == f"{BASE}/comments/c1/react" - assert _last_body(mock_urlopen) == {"emoji": "🔥"} + assert req.full_url == f"{BASE}/reactions/toggle" + assert _last_body(mock_urlopen) == {"emoji": "thumbs_up", "comment_id": "c1"} # --------------------------------------------------------------------------- @@ -569,11 +569,11 @@ def test_get_poll(self, mock_urlopen: MagicMock) -> None: req = _last_request(mock_urlopen) assert req.get_method() == "GET" - assert req.full_url == f"{BASE}/posts/p1/poll" + assert req.full_url == f"{BASE}/polls/p1/results" assert result["options"][0]["text"] == "Yes" @patch("colony_sdk.client.urlopen") - def test_vote_poll(self, mock_urlopen: MagicMock) -> None: + def test_vote_poll_single(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"voted": True}) client = _authed_client() @@ -581,8 +581,18 @@ def test_vote_poll(self, mock_urlopen: MagicMock) -> None: req = _last_request(mock_urlopen) assert req.get_method() == "POST" - assert req.full_url == f"{BASE}/posts/p1/poll/vote" - assert _last_body(mock_urlopen) == {"option_id": "opt1"} + assert req.full_url == f"{BASE}/polls/p1/vote" + assert _last_body(mock_urlopen) == {"option_ids": ["opt1"]} + + @patch("colony_sdk.client.urlopen") + def test_vote_poll_multiple(self, mock_urlopen: MagicMock) -> None: + """Multi-choice polls accept a list of option IDs.""" + mock_urlopen.return_value = _mock_response({"voted": True}) + client = _authed_client() + + client.vote_poll("p1", ["opt1", "opt2"]) + + assert _last_body(mock_urlopen) == {"option_ids": ["opt1", "opt2"]} # --------------------------------------------------------------------------- diff --git a/tests/test_async_client.py b/tests/test_async_client.py index cb47b83..97d2a5b 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -422,28 +422,40 @@ async def test_react_post(self) -> None: seen: dict = {} def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) seen["body"] = json.loads(request.content) - return _json_response({"emoji": "🔥"}) + return _json_response({"toggled": True}) client = _make_client(handler) - await client.react_post("p1", "🔥") - assert seen["body"] == {"emoji": "🔥"} + await client.react_post("p1", "fire") + assert seen["url"].endswith("/reactions/toggle") + assert seen["body"] == {"emoji": "fire", "post_id": "p1"} async def test_react_comment(self) -> None: - client = _make_client(lambda r: _json_response({"emoji": "👍"})) - result = await client.react_comment("c1", "👍") - assert result == {"emoji": "👍"} + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content) + return _json_response({"toggled": True}) + + client = _make_client(handler) + await client.react_comment("c1", "thumbs_up") + assert seen["url"].endswith("/reactions/toggle") + assert seen["body"] == {"emoji": "thumbs_up", "comment_id": "c1"} async def test_vote_poll(self) -> None: seen: dict = {} def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) seen["body"] = json.loads(request.content) return _json_response({"voted": True}) client = _make_client(handler) await client.vote_poll("p1", "opt-1") - assert seen["body"] == {"option_id": "opt-1"} + assert seen["url"].endswith("/polls/p1/vote") + assert seen["body"] == {"option_ids": ["opt-1"]} async def test_send_message(self) -> None: seen: dict = {} diff --git a/tests/test_client.py b/tests/test_client.py index 25b7211..1f363a7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,8 +10,8 @@ def test_colonies_complete(): - """All 9 colonies should be present.""" - assert len(COLONIES) == 9 + """All 10 colonies should be present (9 canonical + test-posts).""" + assert len(COLONIES) == 10 expected = { "general", "questions", @@ -22,6 +22,7 @@ def test_colonies_complete(): "crypto", "agent-economy", "introductions", + "test-posts", } assert set(COLONIES.keys()) == expected From 917abcdd623be970455faa958deef68af35c4910 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 17:47:05 +0100 Subject: [PATCH 4/5] Make integration suite resilient to per-account rate limits and is_tester filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After running the suite end-to-end against the live API, two more classes of issue surfaced that have nothing to do with SDK bugs but break the suite when re-run several times in the same hour: 1. **Per-account write rate limits** are tight: 12 create_post/h, 36 create_comment/h, 12 create_webhook/h, hourly vote_post limit. A single full run is fine, but re-runs collide. 2. **The integration test accounts carry an `is_tester` flag**, which causes the server to *intentionally* hide their posts from listing endpoints (so test traffic doesn't leak into the public feed). Tests that asserted "the just-created session post appears in the colony-filtered listing" can never pass for these accounts. Fixes: - **Rate-limit aware skip hook** (`pytest_runtest_call`) — converts `ColonyRateLimitError` raised during a test into `pytest.skip` via `outcome.force_exception(pytest.skip.Exception(...))`. The test is reported as cleanly skipped with a "rate limited" reason instead of failing. - **`raises_status(*statuses)` helper** in conftest — like `pytest.raises(ColonyAPIError)` but skips on 429 (which the parent- class catch would otherwise swallow into a confusing "assert 429 in (404, ...)" failure). All eight tests that check for specific error status codes now go through this helper. - **Session client fixtures skip on auth-token rate limit** — when `POST /auth/token` is rate-limited (30/h per IP), the primary `client` fixture skips the entire suite cleanly with one message instead of letting every dependent fixture error at setup time. `is_tester` adaptations: - `test_iter_posts_filters_by_colony` and `test_get_posts_filters_by_colony` now verify the filter against the public `general` colony (asserting all returned posts have the expected `colony_id`) rather than trying to find a freshly-created tester post in the listing. - conftest header documents the `is_tester` constraint so future contributors don't add tests with the same pattern. Voting test owner-tracking: - The `test_post` session fixture falls back to the secondary account when the primary's create_post budget is exhausted. That made `test_cannot_vote_on_own_post` flaky because it assumed the primary client owned the post. New `test_post_owner` and `test_post_voter` session fixtures resolve to the actual owner / non-owner so the voting tests work regardless of which account created the fixture post. Webhook tests: - `test_create_list_delete` and `test_create_with_short_secret_rejected` skip cleanly when the webhook 12/h rate limit is hit instead of failing (since they can't actually verify the validation behaviour if they can't reach the endpoint). Result: from a clean rate-limit budget, the suite reports **45 passed, 8 skipped, 15 xfailed (rate limit), 0 failed, 0 errors**. With the new rate-limit-aware skip path, the xfailed count converts to skipped on the next run. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/conftest.py | 138 ++++++++++++++++++++++++++- tests/integration/test_colonies.py | 10 +- tests/integration/test_comments.py | 7 +- tests/integration/test_follow.py | 10 +- tests/integration/test_pagination.py | 24 +++-- tests/integration/test_polls.py | 5 +- tests/integration/test_posts.py | 29 ++++-- tests/integration/test_profile.py | 11 +-- tests/integration/test_voting.py | 67 +++++++------ tests/integration/test_webhooks.py | 51 +++++++--- 10 files changed, 266 insertions(+), 86 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e1a9733..d885c4b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,6 +13,20 @@ See ``tests/integration/README.md`` for the full setup. +## ``is_tester`` accounts + +The dedicated integration test accounts (``integration-tester-account`` +and its sister) are flagged with ``is_tester`` server-side. The server +intentionally **hides their posts from listing endpoints** so test +traffic doesn't leak into the public feed. Tests that just want to +verify "filtering by colony works" therefore exercise the filter +against ``general`` (where there's plenty of public content) and assert +on the colony of returned posts, instead of trying to find a freshly +created tester post in the listing. + +Direct ``get_post(post_id)`` lookups are unaffected — only listing / +search / colony-filter endpoints honour the ``is_tester`` flag. + ## Rate-limit awareness Two server-side limits make this suite tricky to run end-to-end: @@ -48,6 +62,7 @@ from colony_sdk import ( ColonyAPIError, ColonyClient, + ColonyRateLimitError, RetryConfig, ) @@ -109,22 +124,93 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item item.add_marker(skip_marker) +# ── Convert rate-limit failures to skips ──────────────────────────────── +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_call(item: pytest.Item): + """Convert ``ColonyRateLimitError`` raised during a test into a skip. + + Per-account write budgets (12 posts/h, 36 comments/h, hourly vote + limit, 12 webhooks/h) are easy to exhaust if you re-run the suite + several times in the same hour. When that happens, a 429 isn't a + real defect — it's noise. Inject a ``pytest.skip`` so the test is + cleanly marked as skipped (with a clear "rate limited" reason) + instead of producing a confusing failure or xfail. + + Runs only on the call phase — fixture-setup rate limits surface + naturally as errors, which is correct (the fixture itself should + decide whether to skip-or-fail). + """ + outcome = yield + if outcome.excinfo is None: + return + exc = outcome.excinfo[1] + if isinstance(exc, ColonyRateLimitError): + outcome.force_exception( + pytest.skip.Exception( + f"rate limited (re-run after window resets): {exc}", + _use_item_location=True, + ) + ) + + # ── Helpers ───────────────────────────────────────────────────────────── def unique_suffix() -> str: """Short unique tag for test artifact titles/bodies.""" return f"{int(time.time())}-{uuid.uuid4().hex[:6]}" +@contextlib.contextmanager +def raises_status(*expected_statuses: int): + """Like ``pytest.raises(ColonyAPIError)``, but skips on 429. + + Use this in tests that expect a specific error status code (e.g. + 404 for "not found", 400 for validation errors). If the call hits + a 429 rate limit before reaching the validation path, the test + skips with a clear reason instead of producing a confusing + "assert 429 in (404, 422)" failure. + + Example:: + + with raises_status(403, 404) as exc: + client.delete_post("00000000-0000-0000-0000-000000000000") + assert "not found" in str(exc.value).lower() + """ + from types import SimpleNamespace + + info = SimpleNamespace(value=None) + try: + yield info + except ColonyRateLimitError as e: + pytest.skip(f"rate limited (re-run after window resets): {e}") + except ColonyAPIError as e: + if e.status not in expected_statuses: + raise AssertionError(f"expected status in {expected_statuses}, got {e.status}: {e}") from e + info.value = e + else: + raise AssertionError(f"expected ColonyAPIError with status in {expected_statuses}, got nothing") + + # ── Sync client fixtures ──────────────────────────────────────────────── @pytest.fixture(scope="session") def client() -> ColonyClient: - """Authenticated sync client for the **primary** test account.""" + """Authenticated sync client for the **primary** test account. + + Skips the entire suite cleanly if ``POST /auth/token`` is rate + limited (30/h per IP) — every other fixture transitively depends + on this one, so a hard error here would produce dozens of confusing + setup errors instead of a single clear "rate limited" message. + """ assert API_KEY is not None # guarded by pytest_collection_modifyitems c = ColonyClient(API_KEY, retry=NO_RETRY) _prime_from_cache(c, API_KEY) # Trigger one token fetch up front and seed the cache so async # fixtures (which build new clients later) don't have to. - c.get_me() + try: + c.get_me() + except ColonyRateLimitError as e: + pytest.skip( + f"auth-token rate limited (30/h per IP) — re-run from a different IP or wait for the window to reset: {e}" + ) _save_to_cache(c, API_KEY) return c @@ -146,7 +232,10 @@ def second_client() -> ColonyClient: pytest.skip("set COLONY_TEST_API_KEY_2 to run cross-user tests") c = ColonyClient(API_KEY_2, retry=NO_RETRY) _prime_from_cache(c, API_KEY_2) - c.get_me() + try: + c.get_me() + except ColonyRateLimitError as e: + pytest.skip(f"auth-token rate limited for secondary account: {e}") _save_to_cache(c, API_KEY_2) return c @@ -213,6 +302,13 @@ def _try_create_session_post(c: ColonyClient) -> dict | None: raise +# Module-level handle to the client that owns the session test post. +# Tests that need to act AS the post's owner (e.g. self-vote rejection +# tests) read this via the ``test_post_owner`` fixture so they don't +# break when ``test_post`` falls back to the secondary account. +_TEST_POST_OWNER: ColonyClient | None = None + + @pytest.fixture(scope="session") def test_post(client: ColonyClient) -> Iterator[dict]: """One shared discussion post for the whole test session. @@ -222,18 +318,21 @@ def test_post(client: ColonyClient) -> Iterator[dict]: both accounts are rate-limited, every test that depends on this fixture is skipped — runs that don't need a post still go through. """ + global _TEST_POST_OWNER post = _try_create_session_post(client) cleanup_client: ColonyClient | None = client + _TEST_POST_OWNER = client if post else None if post is None and API_KEY_2: secondary = ColonyClient(API_KEY_2, retry=NO_RETRY) _prime_from_cache(secondary, API_KEY_2) post = _try_create_session_post(secondary) cleanup_client = secondary if post else None + _TEST_POST_OWNER = secondary if post else None if post is None: pytest.skip( - "create_post rate-limited on every available account (10/hour per agent) — wait for the limit to reset" + "create_post rate-limited on every available account (12/hour per agent) — wait for the limit to reset" ) try: @@ -242,6 +341,37 @@ def test_post(client: ColonyClient) -> Iterator[dict]: if cleanup_client is not None: with contextlib.suppress(ColonyAPIError): cleanup_client.delete_post(post["id"]) + _TEST_POST_OWNER = None + + +@pytest.fixture(scope="session") +def test_post_owner(test_post: dict) -> ColonyClient: + """The client that owns ``test_post``. + + Use this in tests that need to act *as the author* of the session + post — e.g. testing that the server rejects self-votes. The owner + may be either the primary or secondary client depending on which + account had budget when the fixture ran. + """ + assert _TEST_POST_OWNER is not None # set by test_post fixture + return _TEST_POST_OWNER + + +@pytest.fixture(scope="session") +def test_post_voter( + test_post_owner: ColonyClient, + client: ColonyClient, + second_client: ColonyClient, +) -> ColonyClient: + """A client that is **not** ``test_post``'s owner — safe to vote. + + Use this in tests that need to perform a cross-user vote on the + session test post. Resolves to the secondary if the primary owns + the post and vice versa. + """ + if test_post_owner is client: + return second_client + return client @pytest.fixture diff --git a/tests/integration/test_colonies.py b/tests/integration/test_colonies.py index 06b5c8d..16e011d 100644 --- a/tests/integration/test_colonies.py +++ b/tests/integration/test_colonies.py @@ -8,11 +8,9 @@ import contextlib -import pytest - from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_ID, items_of +from .conftest import TEST_POSTS_COLONY_ID, items_of, raises_status class TestColonies: @@ -25,9 +23,8 @@ def test_join_then_leave(self, client: ColonyClient) -> None: assert isinstance(result, dict) try: - with pytest.raises(ColonyAPIError) as exc_info: + with raises_status(409): client.join_colony(TEST_POSTS_COLONY_ID) - assert exc_info.value.status == 409 finally: client.leave_colony(TEST_POSTS_COLONY_ID) @@ -35,9 +32,8 @@ def test_leave_when_not_member_raises(self, client: ColonyClient) -> None: with contextlib.suppress(ColonyAPIError): client.leave_colony(TEST_POSTS_COLONY_ID) - with pytest.raises(ColonyAPIError) as exc_info: + with raises_status(404, 409): client.leave_colony(TEST_POSTS_COLONY_ID) - assert exc_info.value.status in (404, 409) def test_get_colonies_lists_test_posts(self, client: ColonyClient) -> None: """``get_colonies`` should return a list containing test-posts.""" diff --git a/tests/integration/test_comments.py b/tests/integration/test_comments.py index abe62e3..fd68281 100644 --- a/tests/integration/test_comments.py +++ b/tests/integration/test_comments.py @@ -7,7 +7,7 @@ from __future__ import annotations -from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError +from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError, ColonyRateLimitError from .conftest import items_of, unique_suffix @@ -59,10 +59,13 @@ def test_get_comments_for_nonexistent_post(self, client: ColonyClient) -> None: """A 404 from the comments endpoint should surface as an API error. Some endpoints may return an empty list for unknown post IDs - rather than 404 — accept either behaviour. + rather than 404 — accept either behaviour. Rate limits skip + cleanly via the conftest hook. """ try: result = client.get_comments("00000000-0000-0000-0000-000000000000") + except ColonyRateLimitError: + raise # let the conftest hook convert to skip except (ColonyNotFoundError, ColonyAPIError) as e: assert e.status in (404, 422) else: diff --git a/tests/integration/test_follow.py b/tests/integration/test_follow.py index fa95fbf..a5f6450 100644 --- a/tests/integration/test_follow.py +++ b/tests/integration/test_follow.py @@ -8,10 +8,10 @@ import contextlib -import pytest - from colony_sdk import ColonyAPIError, ColonyClient +from .conftest import raises_status + class TestFollow: def test_follow_then_unfollow(self, client: ColonyClient, second_me: dict) -> None: @@ -24,9 +24,8 @@ def test_follow_then_unfollow(self, client: ColonyClient, second_me: dict) -> No assert result.get("status") == "following" try: - with pytest.raises(ColonyAPIError) as exc_info: + with raises_status(409): client.follow(target_id) - assert exc_info.value.status == 409 finally: client.unfollow(target_id) @@ -36,6 +35,5 @@ def test_unfollow_when_not_following_raises(self, client: ColonyClient, second_m with contextlib.suppress(ColonyAPIError): client.unfollow(target_id) - with pytest.raises(ColonyAPIError) as exc_info: + with raises_status(404, 409): client.unfollow(target_id) - assert exc_info.value.status in (404, 409) diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py index 7ad19ab..494bb4c 100644 --- a/tests/integration/test_pagination.py +++ b/tests/integration/test_pagination.py @@ -10,9 +10,9 @@ from __future__ import annotations -from colony_sdk import ColonyClient +from colony_sdk import COLONIES, ColonyClient -from .conftest import TEST_POSTS_COLONY_NAME, unique_suffix +from .conftest import unique_suffix class TestIterPosts: @@ -41,10 +41,22 @@ def test_iter_posts_respects_max_results_smaller_than_page(self, client: ColonyC posts = list(client.iter_posts(page_size=20, max_results=3)) assert len(posts) == 3 - def test_iter_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: - """Filtered iteration includes the session test post.""" - ids = [p["id"] for p in client.iter_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", max_results=20)] - assert test_post["id"] in ids + def test_iter_posts_filters_by_colony(self, client: ColonyClient) -> None: + """Filtered iteration returns only posts from the requested colony. + + Uses ``general`` instead of ``test-posts`` because test-posts + content is intentionally hidden from listing endpoints by the + server, so a freshly-created session post would never show up + in the filtered listing even though the filter itself works. + """ + general_id = COLONIES["general"] + posts = list(client.iter_posts(colony="general", sort="new", max_results=10)) + assert len(posts) > 0, "general colony has no recent posts" + for p in posts: + if "colony_id" in p: + assert p["colony_id"] == general_id, ( + f"post {p['id']} has colony_id {p['colony_id']} but filter requested {general_id}" + ) class TestIterComments: diff --git a/tests/integration/test_polls.py b/tests/integration/test_polls.py index 750746e..fa2d639 100644 --- a/tests/integration/test_polls.py +++ b/tests/integration/test_polls.py @@ -14,7 +14,7 @@ from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_NAME +from .conftest import TEST_POSTS_COLONY_NAME, raises_status def _find_a_poll(client: ColonyClient) -> dict | None: @@ -43,9 +43,8 @@ def test_get_poll_against_real_poll(self, client: ColonyClient) -> None: 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 pytest.raises(ColonyAPIError) as exc_info: + with raises_status(400, 404, 422): client.get_poll(test_post["id"]) - assert exc_info.value.status in (400, 404, 422) @pytest.mark.skipif( not os.environ.get("COLONY_TEST_POLL_ID"), diff --git a/tests/integration/test_posts.py b/tests/integration/test_posts.py index 8135160..b09d4da 100644 --- a/tests/integration/test_posts.py +++ b/tests/integration/test_posts.py @@ -12,12 +12,13 @@ import pytest -from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError +from colony_sdk import COLONIES, ColonyAPIError, ColonyClient, ColonyNotFoundError from .conftest import ( TEST_POSTS_COLONY_ID, TEST_POSTS_COLONY_NAME, items_of, + raises_status, unique_suffix, ) @@ -89,9 +90,8 @@ def test_get_nonexistent_post_raises_not_found(self, client: ColonyClient) -> No assert exc_info.value.status == 404 def test_delete_nonexistent_post_raises(self, client: ColonyClient) -> None: - with pytest.raises(ColonyAPIError) as exc_info: + with raises_status(403, 404): client.delete_post("00000000-0000-0000-0000-000000000000") - assert exc_info.value.status in (403, 404) class TestPostListing: @@ -107,11 +107,24 @@ def test_get_posts_returns_list(self, client: ColonyClient) -> None: assert "id" in post assert "title" in post - def test_get_posts_filters_by_colony(self, client: ColonyClient, test_post: dict) -> None: - """Filtering by colony should at least include the session test post.""" - result = client.get_posts(colony=TEST_POSTS_COLONY_NAME, sort="new", limit=20) - ids = [p["id"] for p in items_of(result)] - assert test_post["id"] in ids + def test_get_posts_filters_by_colony(self, client: ColonyClient) -> None: + """Filtering by colony returns only posts from that colony. + + Uses ``general`` instead of ``test-posts`` because the + integration test accounts carry an ``is_tester`` flag — their + posts are intentionally hidden from listing endpoints, so a + freshly-created session post would never appear in the + filtered listing even though the filter itself works. + """ + general_id = COLONIES["general"] + result = client.get_posts(colony="general", sort="new", limit=10) + posts = items_of(result) + assert len(posts) > 0, "general colony has no recent posts" + for p in posts: + if "colony_id" in p: + assert p["colony_id"] == general_id, ( + f"post {p['id']} has colony_id {p['colony_id']} but filter requested {general_id}" + ) def test_get_posts_sort_orders_accepted(self, client: ColonyClient) -> None: """The four documented sort orders should all return without error.""" diff --git a/tests/integration/test_profile.py b/tests/integration/test_profile.py index 40ba064..13f70df 100644 --- a/tests/integration/test_profile.py +++ b/tests/integration/test_profile.py @@ -2,11 +2,9 @@ from __future__ import annotations -import pytest +from colony_sdk import ColonyClient -from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError - -from .conftest import unique_suffix +from .conftest import raises_status, unique_suffix class TestProfile: @@ -22,9 +20,8 @@ def test_get_user_by_id(self, client: ColonyClient, me: dict) -> None: assert result["username"] == me["username"] def test_get_nonexistent_user_raises(self, client: ColonyClient) -> None: - with pytest.raises((ColonyNotFoundError, ColonyAPIError)) as exc_info: + with raises_status(404, 422): client.get_user("00000000-0000-0000-0000-000000000000") - assert exc_info.value.status in (404, 422) def test_update_profile_round_trip(self, client: ColonyClient, me: dict) -> None: """Update bio to a unique value, verify it sticks, restore original.""" @@ -49,5 +46,5 @@ def test_search_returns_dict(self, client: ColonyClient) -> None: def test_search_with_short_query(self, client: ColonyClient) -> None: """Queries shorter than the documented minimum should error.""" - with pytest.raises(ColonyAPIError): + with raises_status(400, 422): client.search("a", limit=5) diff --git a/tests/integration/test_voting.py b/tests/integration/test_voting.py index 0fd5417..7a694c7 100644 --- a/tests/integration/test_voting.py +++ b/tests/integration/test_voting.py @@ -4,8 +4,8 @@ elsewhere: * You **cannot vote on your own post** ("Cannot vote on your own post"). - Voting tests therefore use the secondary account as the voter and the - primary account's session test post as the target. + Voting tests therefore use the ``test_post_voter`` fixture (the client + that is *not* the post's owner) so the vote is always cross-user. * ``vote_post`` only accepts ``+1`` or ``-1`` — value ``0`` is rejected ("Vote value must be 1 or -1"). There is no "clear vote" semantic on this endpoint. @@ -24,39 +24,48 @@ class TestVoting: - def test_secondary_upvotes_primary_post(self, second_client: ColonyClient, test_post: dict) -> None: - """Secondary upvotes the session test post (which primary owns).""" - result = second_client.vote_post(test_post["id"], value=1) + def test_voter_upvotes_test_post(self, test_post_voter: ColonyClient, test_post: dict) -> None: + """The non-owning voter upvotes the session test post.""" + result = test_post_voter.vote_post(test_post["id"], value=1) assert isinstance(result, dict) - def test_secondary_downvotes_primary_post(self, second_client: ColonyClient, test_post: dict) -> None: - """Secondary downvotes the session test post.""" - result = second_client.vote_post(test_post["id"], value=-1) + def test_voter_downvotes_test_post(self, test_post_voter: ColonyClient, test_post: dict) -> None: + """The non-owning voter downvotes the session test post.""" + result = test_post_voter.vote_post(test_post["id"], value=-1) assert isinstance(result, dict) - def test_cannot_vote_on_own_post(self, client: ColonyClient, test_post: dict) -> None: - """Server rejects votes on your own posts with 400.""" + def test_cannot_vote_on_own_post(self, test_post_owner: ColonyClient, test_post: dict) -> None: + """Server rejects votes on your own posts. + + Uses ``test_post_owner`` (the client that actually created the + session post) so this works regardless of whether the primary + or secondary account owned the fixture post. + """ with pytest.raises(ColonyAPIError) as exc_info: - client.vote_post(test_post["id"], value=1) + test_post_owner.vote_post(test_post["id"], value=1) + if exc_info.value.status == 429: + pytest.skip("hourly vote limit reached — re-run after the window resets") assert exc_info.value.status in (400, 422) assert "own post" in str(exc_info.value).lower() - def test_vote_invalid_value_rejected(self, second_client: ColonyClient, test_post: dict) -> None: + def test_vote_invalid_value_rejected(self, test_post_voter: ColonyClient, test_post: dict) -> None: """Vote values outside {-1, 1} are rejected.""" for bad_value in (0, 99, -2): with pytest.raises(ColonyAPIError) as exc_info: - second_client.vote_post(test_post["id"], value=bad_value) + test_post_voter.vote_post(test_post["id"], value=bad_value) + if exc_info.value.status == 429: + pytest.skip("hourly vote limit reached — re-run after the window resets") assert exc_info.value.status in (400, 422), f"value={bad_value}" def test_vote_comment_cross_user( self, - client: ColonyClient, - second_client: ColonyClient, + test_post_owner: ColonyClient, + test_post_voter: ColonyClient, test_post: dict, ) -> None: - """Primary creates a comment, secondary votes on it.""" - comment = client.create_comment(test_post["id"], "vote-target comment") - result = second_client.vote_comment(comment["id"], value=1) + """Owner creates a comment, the other client votes on it.""" + comment = test_post_owner.create_comment(test_post["id"], "vote-target comment") + result = test_post_voter.vote_comment(comment["id"], value=1) assert isinstance(result, dict) @@ -67,27 +76,27 @@ class TestReactions: which never existed on the server. Fixed in this release. """ - def test_react_to_post_is_a_toggle(self, second_client: ColonyClient, test_post: dict) -> None: + def test_react_to_post_is_a_toggle(self, test_post_voter: ColonyClient, test_post: dict) -> None: """Reactions are toggles — calling twice with the same emoji removes it.""" - result_a = second_client.react_post(test_post["id"], emoji="fire") + result_a = test_post_voter.react_post(test_post["id"], emoji="fire") assert isinstance(result_a, dict) - result_b = second_client.react_post(test_post["id"], emoji="fire") + result_b = test_post_voter.react_post(test_post["id"], emoji="fire") assert isinstance(result_b, dict) def test_react_to_comment_is_a_toggle( self, - client: ColonyClient, - second_client: ColonyClient, + test_post_owner: ColonyClient, + test_post_voter: ColonyClient, test_post: dict, ) -> None: - comment = client.create_comment(test_post["id"], "react-target comment") - second_client.react_comment(comment["id"], emoji="thumbs_up") - second_client.react_comment(comment["id"], emoji="thumbs_up") + comment = test_post_owner.create_comment(test_post["id"], "react-target comment") + test_post_voter.react_comment(comment["id"], emoji="thumbs_up") + test_post_voter.react_comment(comment["id"], emoji="thumbs_up") - def test_react_with_multiple_emojis(self, second_client: ColonyClient, test_post: dict) -> None: + def test_react_with_multiple_emojis(self, test_post_voter: ColonyClient, test_post: dict) -> None: """Multiple distinct emoji reactions should coexist on a post.""" for emoji in ("rocket", "fire", "heart"): - second_client.react_post(test_post["id"], emoji=emoji) + test_post_voter.react_post(test_post["id"], emoji=emoji) # Toggle them back off so the test post stays clean. for emoji in ("rocket", "fire", "heart"): - second_client.react_post(test_post["id"], emoji=emoji) + test_post_voter.react_post(test_post["id"], emoji=emoji) diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py index c3f5c3c..5784811 100644 --- a/tests/integration/test_webhooks.py +++ b/tests/integration/test_webhooks.py @@ -1,23 +1,39 @@ -"""Integration tests for webhook CRUD endpoints.""" +"""Integration tests for webhook CRUD endpoints. + +Webhooks are aggressively rate-limited (12 create_webhook per hour per +agent). When that budget is exhausted, this file's tests skip with a +clear reason instead of failing — re-runs in the same hour will still +exercise everything else cleanly. +""" from __future__ import annotations import pytest -from colony_sdk import ColonyAPIError, ColonyClient +from colony_sdk import ColonyAPIError, ColonyClient, ColonyRateLimitError from .conftest import unique_suffix +def _skip_if_webhook_rate_limited(exc: ColonyAPIError) -> None: + if isinstance(exc, ColonyRateLimitError) or getattr(exc, "status", None) == 429: + pytest.skip("webhook rate limit (12/hour per agent) reached — re-run after the window resets") + + class TestWebhooks: def test_create_list_delete(self, client: ColonyClient) -> None: """Full create → list → delete lifecycle.""" suffix = unique_suffix() - result = client.create_webhook( - url=f"https://test.clny.cc/integration-{suffix}", - events=["post_created", "mention"], - secret=f"integration-test-secret-{suffix}", - ) + try: + result = client.create_webhook( + url=f"https://test.clny.cc/integration-{suffix}", + events=["post_created", "mention"], + secret=f"integration-test-secret-{suffix}", + ) + except ColonyAPIError as e: + _skip_if_webhook_rate_limited(e) + raise + assert "id" in result assert result["url"] == f"https://test.clny.cc/integration-{suffix}" assert sorted(result["events"]) == ["mention", "post_created"] @@ -43,11 +59,18 @@ def test_delete_nonexistent_raises(self, client: ColonyClient) -> None: def test_create_with_short_secret_rejected(self, client: ColonyClient) -> None: """Webhook secrets must be at least 16 characters.""" - with pytest.raises(ColonyAPIError) as exc_info: - client.create_webhook( - url="https://test.clny.cc/short-secret", - events=["post_created"], - secret="short", - ) - # 422 for validation, 400 for bad request + try: + with pytest.raises(ColonyAPIError) as exc_info: + client.create_webhook( + url="https://test.clny.cc/short-secret", + events=["post_created"], + secret="short", + ) + except Exception: + raise + + # If the rate limit hit before validation could run, we can't + # actually test the validation behaviour — skip rather than fail. + 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) From e3e3d59588b0a032773e31357bb4c3e43e4a751d Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 18:57:31 +0100 Subject: [PATCH 5/5] test_comment fixture skips on rate limit instead of erroring The fixture was raising ColonyRateLimitError at setup time when the 36/hour create_comment budget was exhausted. The conftest hook only intercepts call-phase exceptions, so dependent tests showed as ERROR instead of SKIPPED. Wrapping the create_comment call in a try/except that calls pytest.skip() converts those into clean fixture-level skips that propagate to dependents. End-to-end run against the live API now reports **48 passed, 20 skipped, 0 failed, 0 errors** even with rate limits partially exhausted from re-runs. On a fresh hour with clean budgets the suite reports ~63 passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d885c4b..b1ffe12 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -379,9 +379,14 @@ def test_comment(client: ColonyClient, test_post: dict) -> dict: """Create a fresh comment on the shared session post. Function-scoped so each test that needs a known-new comment ID gets - one. Comments are not subject to the same per-hour limit as posts. + one. Skips cleanly on rate limit (36 create_comment per agent per + hour) instead of erroring at fixture setup, so dependent tests + show as skipped rather than as errors. """ - return client.create_comment(test_post["id"], f"Integration test comment {unique_suffix()}.") + try: + return client.create_comment(test_post["id"], f"Integration test comment {unique_suffix()}.") + except ColonyRateLimitError as e: + pytest.skip(f"comment rate limited (re-run after window resets): {e}") # ── Helpers for envelope unwrapping ─────────────────────────────────────