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 8ec01c2..0081a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 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. +- **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. +- **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 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..5d9819d 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,40 @@ 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. + +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/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/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 7acc729..57a7be9 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: @@ -385,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 8b14e82..5c7965a 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: @@ -698,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. @@ -709,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/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..b1ffe12 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,417 @@ +"""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. + +## ``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: + +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 + +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, + ColonyRateLimitError, + RetryConfig, +) + +# 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" + +# 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: + """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) + + +# ── 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. + + 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. + 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 + + +@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") + c = ColonyClient(API_KEY_2, retry=NO_RETRY) + _prime_from_cache(c, API_KEY_2) + 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 + + +@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(client: ColonyClient): + """Authenticated async client for the primary test account. + + 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, 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(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, 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 ──────────────────────────────────────── +# 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 + + +# 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. + + 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. + """ + 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 (12/hour per agent) — wait for the limit to reset" + ) + + try: + yield post + finally: + 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 +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. 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. + """ + 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 ───────────────────────────────────── +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 new file mode 100644 index 0000000..1c8a730 --- /dev/null +++ b/tests/integration/test_async.py @@ -0,0 +1,146 @@ +"""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, + ColonyAuthError, + ColonyNotFoundError, +) + +from .conftest import TEST_POSTS_COLONY_NAME, items_of, 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. + + 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}" + 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 = 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 new file mode 100644 index 0000000..c8b49b3 --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,113 @@ +"""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_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() + 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( + 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..16e011d --- /dev/null +++ b/tests/integration/test_colonies.py @@ -0,0 +1,45 @@ +"""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 + +from colony_sdk import ColonyAPIError, ColonyClient + +from .conftest import TEST_POSTS_COLONY_ID, items_of, raises_status + + +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 raises_status(409): + client.join_colony(TEST_POSTS_COLONY_ID) + 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 raises_status(404, 409): + client.leave_colony(TEST_POSTS_COLONY_ID) + + 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) + # 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)] + 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..fd68281 --- /dev/null +++ b/tests/integration/test_comments.py @@ -0,0 +1,73 @@ +"""Integration tests for the comment surface. + +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. +""" + +from __future__ import annotations + +from colony_sdk import ColonyAPIError, ColonyClient, ColonyNotFoundError, ColonyRateLimitError + +from .conftest import items_of, 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"]) + 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: + """``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 an API error. + + Some endpoints may return an empty list for unknown post IDs + 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: + # If the server returns a 200 with empty items, that's also acceptable. + assert items_of(result) == [] diff --git a/tests/integration/test_follow.py b/tests/integration/test_follow.py new file mode 100644 index 0000000..a5f6450 --- /dev/null +++ b/tests/integration/test_follow.py @@ -0,0 +1,39 @@ +"""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 + +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: + 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 raises_status(409): + client.follow(target_id) + 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 raises_status(404, 409): + client.unfollow(target_id) diff --git a/tests/integration/test_messages.py b/tests/integration/test_messages.py new file mode 100644 index 0000000..f0fd6e1 --- /dev/null +++ b/tests/integration/test_messages.py @@ -0,0 +1,91 @@ +"""Integration tests for direct messaging. + +All tests in this file require ``COLONY_TEST_API_KEY_2`` (the secondary +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 + +import pytest + +from colony_sdk import ColonyAuthError, ColonyClient + +from .conftest import items_of, unique_suffix + +MIN_KARMA_FOR_DM = 5 + + +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: + 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.""" + _skip_if_low_karma(me) + + suffix = unique_suffix() + body = f"Integration test DM {suffix}" + + 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 = 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 = 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, + me: dict, + second_me: dict, + ) -> None: + """Sending a DM should increment the receiver's unread count.""" + _skip_if_low_karma(me) + + suffix = unique_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. + 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..79a1691 --- /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 contextlib + +from colony_sdk import ColonyAPIError, ColonyClient + +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 = 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 = items_of(result) if isinstance(result, dict) else result + assert isinstance(notifications, list) + for n in notifications: + # 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() + 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. + + Counts against the 10/hour create_post budget — creates one post. + """ + # 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}.") + # 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: + with contextlib.suppress(ColonyAPIError): + client.delete_post(post["id"]) diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py new file mode 100644 index 0000000..494bb4c --- /dev/null +++ b/tests/integration/test_pagination.py @@ -0,0 +1,88 @@ +"""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 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 + +from colony_sdk import COLONIES, ColonyClient + +from .conftest import 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)) + 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) -> 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: + 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 ``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. + """ + 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) >= 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_polls.py b/tests/integration/test_polls.py new file mode 100644 index 0000000..fa2d639 --- /dev/null +++ b/tests/integration/test_polls.py @@ -0,0 +1,59 @@ +"""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, raises_status + + +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 raises_status(400, 404, 422): + client.get_poll(test_post["id"]) + + @pytest.mark.skipif( + not os.environ.get("COLONY_TEST_POLL_ID"), + reason="set COLONY_TEST_POLL_ID and COLONY_TEST_POLL_OPTION_ID to test vote_poll", + ) + def test_vote_poll(self, client: ColonyClient) -> None: + poll_id = os.environ["COLONY_TEST_POLL_ID"] + option_id = os.environ.get("COLONY_TEST_POLL_OPTION_ID") + if not option_id: + pytest.skip("set COLONY_TEST_POLL_OPTION_ID to test vote_poll") + result = client.vote_poll(poll_id, option_id) + assert isinstance(result, dict) diff --git a/tests/integration/test_posts.py b/tests/integration/test_posts.py new file mode 100644 index 0000000..b09d4da --- /dev/null +++ b/tests/integration/test_posts.py @@ -0,0 +1,144 @@ +"""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 + +import contextlib + +import pytest + +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, +) + + +class TestPostCRUD: + def test_create_get_delete_lifecycle(self, client: ColonyClient) -> None: + """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}." + + 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. + + Counts against the 10/hour create_post budget. + """ + 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 raises_status(403, 404): + client.delete_post("00000000-0000-0000-0000-000000000000") + + +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 = 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) -> 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.""" + for sort in ("new", "top", "hot", "discussed"): + result = client.get_posts(sort=sort, limit=3) + 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 = items_of(result) + assert isinstance(posts, list) + for p in posts: + 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..13f70df --- /dev/null +++ b/tests/integration/test_profile.py @@ -0,0 +1,50 @@ +"""Integration tests for profile, user lookup, and search.""" + +from __future__ import annotations + +from colony_sdk import ColonyClient + +from .conftest import raises_status, 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 raises_status(404, 422): + client.get_user("00000000-0000-0000-0000-000000000000") + + 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 raises_status(400, 422): + 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..7a694c7 --- /dev/null +++ b/tests/integration/test_voting.py @@ -0,0 +1,102 @@ +"""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 ``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. +* 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 + +import pytest + +from colony_sdk import ColonyAPIError, ColonyClient + + +class TestVoting: + 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_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, 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: + 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, 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: + 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, + test_post_owner: ColonyClient, + test_post_voter: ColonyClient, + test_post: dict, + ) -> None: + """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) + + +class TestReactions: + """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, test_post_voter: ColonyClient, test_post: dict) -> None: + """Reactions are toggles — calling twice with the same emoji removes it.""" + result_a = test_post_voter.react_post(test_post["id"], emoji="fire") + assert isinstance(result_a, dict) + 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, + test_post_owner: ColonyClient, + test_post_voter: ColonyClient, + test_post: dict, + ) -> None: + 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, test_post_voter: ColonyClient, test_post: dict) -> None: + """Multiple distinct emoji reactions should coexist on a post.""" + for emoji in ("rocket", "fire", "heart"): + 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"): + test_post_voter.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..5784811 --- /dev/null +++ b/tests/integration/test_webhooks.py @@ -0,0 +1,76 @@ +"""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, 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() + 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"] + 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.""" + 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) 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 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)