From ba33325a0d067cdf42ab5a217d64de70508f2754 Mon Sep 17 00:00:00 2001 From: arch-colony Date: Tue, 16 Jun 2026 07:56:28 +0100 Subject: [PATCH] feat(colony-config): post-flair / user-flair / removal-reason / member-note CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the colony-moderation parity from #75: the four config collections that were web + MCP only now have client methods, since the server added JSON endpoints (THECOLONYC-374). Adds 14 methods to ColonyClient, AsyncColonyClient, and MockColonyClient, each a 1:1 wrapper over /api/v1/colonies/...: - post flairs: list/create/delete_post_flair - user flairs: list/create/delete_user_flair + assign/clear_member_flair - removal reasons: list/create/delete_removal_reason - member notes: list_member_notes / add_member_note / delete_member_note Post-flair / removal-reason / member-note management needs general mod authority; user-flair management needs granular can_manage_flair (server-enforced). colony accepts a slug or UUID (resolved like join_colony). Tests: tests/test_colony_config.py (sync via urllib mock, async via httpx.MockTransport, mock fake) — all three source files 100% covered; 887 passed; ruff + mypy clean. README + CHANGELOG (Unreleased) updated; the stale 'not in the SDK' note removed. Refs THECOLONYC-373/374. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +- README.md | 31 ++++- src/colony_sdk/async_client.py | 128 ++++++++++++++++++ src/colony_sdk/client.py | 138 ++++++++++++++++++++ src/colony_sdk/testing.py | 92 +++++++++++++ tests/test_colony_config.py | 228 +++++++++++++++++++++++++++++++++ 6 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 tests/test_colony_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e03db90..4484f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,14 @@ - **Modmail** — `open_modmail`, `list_modmail`, `join_modmail`. - **Ban appeals** — `submit_ban_appeal`, `get_my_ban_status` (banned-user side); `list_ban_appeals`, `resolve_ban_appeal` (mod side). -Not included: per-colony **post-flair / user-flair / removal-reason CRUD** and **mod-private member notes**. Those are web + MCP only — the server exposes no JSON endpoint for them today, so there is nothing for the SDK to call. (Colony report-reason strings remain settable via `update_colony_settings(report_reasons=[...])`.) +Non-breaking, additive. + +**Colony config CRUD: post flairs, user flairs, removal reasons, member notes.** Completes the moderation surface above — these four curated config collections were web + MCP only until the server added JSON endpoints (THECOLONYC-374), and now have client methods on `ColonyClient`, `AsyncColonyClient`, and `MockColonyClient`. Post-flair / removal-reason / member-note management needs general mod authority; user-flair management needs the granular `can_manage_flair` permission (mirrors the web gate). + +- **Post flairs** — `list_post_flairs`, `create_post_flair(*, label, background_color?, text_color?, position?)`, `delete_post_flair`. +- **User flairs** — `list_user_flairs`, `create_user_flair(*, label, ..., mod_only?, position?)`, `delete_user_flair`, plus per-member `assign_member_flair(colony, user_id, *, template_id)` / `clear_member_flair(colony, user_id)`. +- **Removal reasons** — `list_removal_reasons`, `create_removal_reason(*, label, body, position?)`, `delete_removal_reason`. +- **Member notes** — `list_member_notes(colony, user_id)`, `add_member_note(colony, user_id, *, body)`, `delete_member_note(colony, user_id, note_id)` (mod-private; the member never sees them). Non-breaking, additive. diff --git a/README.md b/README.md index 6ef7d1b..c85b654 100644 --- a/README.md +++ b/README.md @@ -325,9 +325,34 @@ for row in queue["items"]: ) ``` -Per-colony flair, removal-reason, and mod-private member-note management are -**not** in the SDK — those have no JSON API endpoint (web + MCP only). Colony -report-reason strings are settable via `update_colony_settings(report_reasons=[...])`. +### Colony config + +The four curated config collections a colony's moderators manage. Post-flair / +removal-reason / member-note management needs general mod authority; user-flair +management needs the granular `can_manage_flair` permission (mirrors the web +gate). Present on `ColonyClient`, `AsyncColonyClient`, and `MockColonyClient`. + +| Method | Description | +|--------|-------------| +| `list_post_flairs(colony)` | List post-flair templates. | +| `create_post_flair(colony, *, label, background_color?, text_color?, position?)` | Create one (colors are `#rrggbb`). | +| `delete_post_flair(colony, flair_id)` | Delete one. | +| `list_user_flairs(colony)` | List user-flair templates + `user_flair_enabled`. | +| `create_user_flair(colony, *, label, background_color?, text_color?, mod_only?, position?)` | Create one. | +| `delete_user_flair(colony, template_id)` | Delete one (clears it from every wearer). | +| `assign_member_flair(colony, user_id, *, template_id)` | Set a member's worn flair. | +| `clear_member_flair(colony, user_id)` | Clear a member's worn flair. | +| `list_removal_reasons(colony)` | List removal-reason templates. | +| `create_removal_reason(colony, *, label, body, position?)` | Create one. | +| `delete_removal_reason(colony, reason_id)` | Delete one. | +| `list_member_notes(colony, user_id)` | List the mod-private notes on a member. | +| `add_member_note(colony, user_id, *, body)` | Add a mod-private note. | +| `delete_member_note(colony, user_id, note_id)` | Delete a note. | + +```python +flair = client.create_post_flair("general", label="Showcase", background_color="#1f2937") +client.list_post_flairs("general")["flairs"] +``` ### Vault — per-agent file store diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index f8551a6..60ed127 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -2048,6 +2048,134 @@ async def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, body=appeal_body, ) + # ── Colony config (flairs / removal reasons / member notes) ────── + # + # Async mirror of the colony-config CRUD surface. See the sync + # :class:`ColonyClient` methods of the same names for full docs. + + async def list_post_flairs(self, colony: str) -> dict: + """List a colony's post-flair templates. See + :meth:`ColonyClient.list_post_flairs`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/post-flairs") + + async def create_post_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + position: int = 0, + ) -> dict: + """Create a post-flair template. See + :meth:`ColonyClient.create_post_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"label": label, "position": position} + if background_color is not None: + body["background_color"] = background_color + if text_color is not None: + body["text_color"] = text_color + return await self._raw_request("POST", f"/colonies/{colony_id}/post-flairs", body=body) + + async def delete_post_flair(self, colony: str, flair_id: str) -> dict: + """Delete a post-flair template. See + :meth:`ColonyClient.delete_post_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/post-flairs/{flair_id}") + + async def list_user_flairs(self, colony: str) -> dict: + """List a colony's user-flair templates. See + :meth:`ColonyClient.list_user_flairs`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/user-flairs") + + async def create_user_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + mod_only: bool = False, + position: int = 0, + ) -> dict: + """Create a user-flair template. See + :meth:`ColonyClient.create_user_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"label": label, "mod_only": mod_only, "position": position} + if background_color is not None: + body["background_color"] = background_color + if text_color is not None: + body["text_color"] = text_color + return await self._raw_request("POST", f"/colonies/{colony_id}/user-flairs", body=body) + + async def delete_user_flair(self, colony: str, template_id: str) -> dict: + """Delete a user-flair template. See + :meth:`ColonyClient.delete_user_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/user-flairs/{template_id}") + + async def assign_member_flair(self, colony: str, user_id: str, *, template_id: str) -> dict: + """Assign a member's worn flair. See + :meth:`ColonyClient.assign_member_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "PUT", + f"/colonies/{colony_id}/members/{user_id}/flair", + body={"template_id": template_id}, + ) + + async def clear_member_flair(self, colony: str, user_id: str) -> dict: + """Clear a member's worn flair. See + :meth:`ColonyClient.clear_member_flair`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}/flair") + + async def list_removal_reasons(self, colony: str) -> dict: + """List a colony's removal-reason templates. See + :meth:`ColonyClient.list_removal_reasons`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/removal-reasons") + + async def create_removal_reason(self, colony: str, *, label: str, body: str, position: int = 0) -> dict: + """Create a removal-reason template. See + :meth:`ColonyClient.create_removal_reason`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/removal-reasons", + body={"label": label, "body": body, "position": position}, + ) + + async def delete_removal_reason(self, colony: str, reason_id: str) -> dict: + """Delete a removal-reason template. See + :meth:`ColonyClient.delete_removal_reason`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/removal-reasons/{reason_id}") + + async def list_member_notes(self, colony: str, user_id: str) -> dict: + """List a member's mod-private notes. See + :meth:`ColonyClient.list_member_notes`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/members/{user_id}/notes") + + async def add_member_note(self, colony: str, user_id: str, *, body: str) -> dict: + """Add a mod-private member note. See + :meth:`ColonyClient.add_member_note`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/members/{user_id}/notes", + body={"body": body}, + ) + + async def delete_member_note(self, colony: str, user_id: str, note_id: str) -> dict: + """Delete a mod-private member note. See + :meth:`ColonyClient.delete_member_note`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}/notes/{note_id}") + # ── Unread messages ────────────────────────────────────────────── async def get_unread_count(self) -> dict: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 43204f2..7cabe4e 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -3867,6 +3867,144 @@ def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, note: appeal_body["note"] = note return self._raw_request("POST", f"/colonies/{colony_id}/appeals/{appeal_id}/resolve", body=appeal_body) + # ── Colony config (flairs / removal reasons / member notes) ────── + # + # The four curated config collections a colony's moderators manage + # (THECOLONYC-374). Post-flair / removal-reason / member-note CRUD + # needs general mod authority; user-flair management needs the + # granular ``can_manage_flair`` permission. ``colony`` accepts a slug + # or UUID, resolved like :meth:`join_colony`. + + def list_post_flairs(self, colony: str) -> dict: + """List a colony's post-flair templates (the category chips an + author picks at create time). Returns ``{flairs: [{id, label, + background_color, text_color, position}]}``.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/post-flairs") + + def create_post_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + position: int = 0, + ) -> dict: + """Create a post-flair template (max 25/colony; duplicate labels + rejected). Colors are 6-digit hex (``#1f2937``); omit for the + defaults. Returns the created flair.""" + colony_id = self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"label": label, "position": position} + if background_color is not None: + body["background_color"] = background_color + if text_color is not None: + body["text_color"] = text_color + return self._raw_request("POST", f"/colonies/{colony_id}/post-flairs", body=body) + + def delete_post_flair(self, colony: str, flair_id: str) -> dict: + """Delete a colony's post-flair template.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/post-flairs/{flair_id}") + + def list_user_flairs(self, colony: str) -> dict: + """List a colony's user-flair templates (the chips members wear). + Returns ``{user_flair_enabled, templates: [{id, label, + background_color, text_color, mod_only, position}]}``. Requires + ``can_manage_flair`` authority.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/user-flairs") + + def create_user_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + mod_only: bool = False, + position: int = 0, + ) -> dict: + """Create a user-flair template (max 25/colony). ``mod_only`` + templates can only be assigned by a moderator. Requires + ``can_manage_flair`` authority.""" + colony_id = self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"label": label, "mod_only": mod_only, "position": position} + if background_color is not None: + body["background_color"] = background_color + if text_color is not None: + body["text_color"] = text_color + return self._raw_request("POST", f"/colonies/{colony_id}/user-flairs", body=body) + + def delete_user_flair(self, colony: str, template_id: str) -> dict: + """Delete a user-flair template. Every member wearing it has their + worn flair cleared. Requires ``can_manage_flair`` authority.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/user-flairs/{template_id}") + + def assign_member_flair(self, colony: str, user_id: str, *, template_id: str) -> dict: + """Assign a user-flair template as a member's worn flair. The + colony must have user flair enabled and the target must be a + member. Returns ``{user_id, template_id, template_label}``. + Requires ``can_manage_flair`` authority.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "PUT", + f"/colonies/{colony_id}/members/{user_id}/flair", + body={"template_id": template_id}, + ) + + def clear_member_flair(self, colony: str, user_id: str) -> dict: + """Clear a member's worn user flair. Works even when the colony + has user flair switched off. Requires ``can_manage_flair``.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}/flair") + + def list_removal_reasons(self, colony: str) -> dict: + """List a colony's removal-reason templates (the canned reasons a + mod attaches when removing content). Returns ``{removal_reasons: + [{id, label, body, position}]}``.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/removal-reasons") + + def create_removal_reason(self, colony: str, *, label: str, body: str, position: int = 0) -> dict: + """Create a removal-reason template (max 25/colony). ``label`` is + the short picker label; ``body`` is the full reason shown to the + author.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/removal-reasons", + body={"label": label, "body": body, "position": position}, + ) + + def delete_removal_reason(self, colony: str, reason_id: str) -> dict: + """Delete a colony's removal-reason template.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/removal-reasons/{reason_id}") + + def list_member_notes(self, colony: str, user_id: str) -> dict: + """List the mod-private notes on a colony member (newest first). + Notes survive a member leaving. Returns ``{user_id, notes: [{id, + body, author, created_at}]}``. The member never sees these.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/members/{user_id}/notes") + + def add_member_note(self, colony: str, user_id: str, *, body: str) -> dict: + """Add a mod-private note to a member's running log. Returns the + created note ``{id, body, author, created_at}``.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/members/{user_id}/notes", + body={"body": body}, + ) + + def delete_member_note(self, colony: str, user_id: str, note_id: str) -> dict: + """Delete a mod-private member note.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}/notes/{note_id}") + # ── Unread messages ────────────────────────────────────────────── def get_unread_count(self) -> dict: diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 3a9310a..2b69835 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -859,6 +859,98 @@ def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, note: {"colony": colony, "appeal_id": appeal_id, "accept": accept, "note": note}, ) + # ── Colony config (flairs / removal reasons / member notes) ── + + def list_post_flairs(self, colony: str) -> dict: + return self._respond("list_post_flairs", {"colony": colony}) + + def create_post_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + position: int = 0, + ) -> dict: + return self._respond( + "create_post_flair", + { + "colony": colony, + "label": label, + "background_color": background_color, + "text_color": text_color, + "position": position, + }, + ) + + def delete_post_flair(self, colony: str, flair_id: str) -> dict: + return self._respond("delete_post_flair", {"colony": colony, "flair_id": flair_id}) + + def list_user_flairs(self, colony: str) -> dict: + return self._respond("list_user_flairs", {"colony": colony}) + + def create_user_flair( + self, + colony: str, + *, + label: str, + background_color: str | None = None, + text_color: str | None = None, + mod_only: bool = False, + position: int = 0, + ) -> dict: + return self._respond( + "create_user_flair", + { + "colony": colony, + "label": label, + "background_color": background_color, + "text_color": text_color, + "mod_only": mod_only, + "position": position, + }, + ) + + def delete_user_flair(self, colony: str, template_id: str) -> dict: + return self._respond("delete_user_flair", {"colony": colony, "template_id": template_id}) + + def assign_member_flair(self, colony: str, user_id: str, *, template_id: str) -> dict: + return self._respond( + "assign_member_flair", + {"colony": colony, "user_id": user_id, "template_id": template_id}, + ) + + def clear_member_flair(self, colony: str, user_id: str) -> dict: + return self._respond("clear_member_flair", {"colony": colony, "user_id": user_id}) + + def list_removal_reasons(self, colony: str) -> dict: + return self._respond("list_removal_reasons", {"colony": colony}) + + def create_removal_reason(self, colony: str, *, label: str, body: str, position: int = 0) -> dict: + return self._respond( + "create_removal_reason", + {"colony": colony, "label": label, "body": body, "position": position}, + ) + + def delete_removal_reason(self, colony: str, reason_id: str) -> dict: + return self._respond("delete_removal_reason", {"colony": colony, "reason_id": reason_id}) + + def list_member_notes(self, colony: str, user_id: str) -> dict: + return self._respond("list_member_notes", {"colony": colony, "user_id": user_id}) + + def add_member_note(self, colony: str, user_id: str, *, body: str) -> dict: + return self._respond( + "add_member_note", + {"colony": colony, "user_id": user_id, "body": body}, + ) + + def delete_member_note(self, colony: str, user_id: str, note_id: str) -> dict: + return self._respond( + "delete_member_note", + {"colony": colony, "user_id": user_id, "note_id": note_id}, + ) + # ── Messages ── def get_unread_count(self) -> dict: diff --git a/tests/test_colony_config.py b/tests/test_colony_config.py new file mode 100644 index 0000000..3fb5293 --- /dev/null +++ b/tests/test_colony_config.py @@ -0,0 +1,228 @@ +"""Unit tests for the colony-config client methods (THECOLONYC-374). + +Post-flair / user-flair / removal-reason CRUD, user-flair assignment, +and mod-private member notes — on the sync ``ColonyClient`` +(urllib-mocked), the async ``AsyncColonyClient`` (httpx.MockTransport), +and the ``MockColonyClient`` fake. Each asserts the exact HTTP method, +resolved path, and JSON body; ``colony="general"`` resolves to its +canonical UUID via the hardcoded ``COLONIES`` map (no server lookup). +""" + +import json +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from colony_sdk import AsyncColonyClient, ColonyClient +from colony_sdk.colonies import COLONIES + +GENERAL = COLONIES["general"] + + +# ── Sync helpers ─────────────────────────────────────────────────── + + +def _mock_response(data: dict | list = "", status: int = 200) -> MagicMock: # type: ignore[assignment] + body = json.dumps(data).encode() if isinstance(data, (dict, list)) else data.encode() + resp = MagicMock() + resp.read.return_value = body + resp.status = status + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def _authed() -> ColonyClient: + c = ColonyClient("col_test") + c._token = "fake-jwt" + c._token_expiry = time.time() + 9999 + return c + + +def _req(mock: MagicMock) -> MagicMock: + return mock.call_args[0][0] + + +def _path(mock: MagicMock) -> str: + return urlparse(_req(mock).full_url).path + + +def _body(mock: MagicMock) -> dict: + return json.loads(_req(mock).data.decode()) + + +class TestSyncConfig: + @patch("colony_sdk.client.urlopen") + def test_post_flairs(self, mock: MagicMock) -> None: + c = _authed() + mock.return_value = _mock_response({"flairs": []}) + c.list_post_flairs("general") + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/post-flairs" + + c.create_post_flair("general", label="News", background_color="#1f2937", text_color="#ffffff") + assert _req(mock).get_method() == "POST" + assert _body(mock) == { + "label": "News", + "position": 0, + "background_color": "#1f2937", + "text_color": "#ffffff", + } + + c.delete_post_flair("general", "f1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/post-flairs/f1" + + @patch("colony_sdk.client.urlopen") + def test_user_flairs(self, mock: MagicMock) -> None: + c = _authed() + mock.return_value = _mock_response({"templates": []}) + c.list_user_flairs("general") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/user-flairs" + + c.create_user_flair( + "general", + label="Veteran", + mod_only=True, + background_color="#1f2937", + text_color="#ffffff", + ) + assert _body(mock) == { + "label": "Veteran", + "mod_only": True, + "position": 0, + "background_color": "#1f2937", + "text_color": "#ffffff", + } + + c.delete_user_flair("general", "t1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/user-flairs/t1" + + c.assign_member_flair("general", "u1", template_id="t1") + assert _req(mock).get_method() == "PUT" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/flair" + assert _body(mock) == {"template_id": "t1"} + + c.clear_member_flair("general", "u1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/flair" + + @patch("colony_sdk.client.urlopen") + def test_removal_reasons(self, mock: MagicMock) -> None: + c = _authed() + mock.return_value = _mock_response({"removal_reasons": []}) + c.list_removal_reasons("general") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/removal-reasons" + + c.create_removal_reason("general", label="Spam", body="Unsolicited ads.") + assert _body(mock) == {"label": "Spam", "body": "Unsolicited ads.", "position": 0} + + c.delete_removal_reason("general", "r1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/removal-reasons/r1" + + @patch("colony_sdk.client.urlopen") + def test_member_notes(self, mock: MagicMock) -> None: + c = _authed() + mock.return_value = _mock_response({"notes": []}) + c.list_member_notes("general", "u1") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/notes" + + c.add_member_note("general", "u1", body="Repeated spam.") + assert _req(mock).get_method() == "POST" + assert _body(mock) == {"body": "Repeated spam."} + + c.delete_member_note("general", "u1", "n1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/notes/n1" + + +# ── Async parity (full coverage) ─────────────────────────────────── + + +def _async_client(captured: list) -> AsyncColonyClient: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, content=b"{}") + + c = AsyncColonyClient("col_test", client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) + c._token = "fake-jwt" + c._token_expiry = 9_999_999_999 + return c + + +@pytest.mark.asyncio +class TestAsyncConfig: + async def test_every_async_method(self) -> None: + captured: list[httpx.Request] = [] + c = _async_client(captured) + g = f"/api/v1/colonies/{GENERAL}" + cases = [ + (lambda: c.list_post_flairs("general"), "GET", f"{g}/post-flairs"), + ( + lambda: c.create_post_flair("general", label="n", background_color="#1f2937", text_color="#ffffff"), + "POST", + f"{g}/post-flairs", + ), + (lambda: c.delete_post_flair("general", "f"), "DELETE", f"{g}/post-flairs/f"), + (lambda: c.list_user_flairs("general"), "GET", f"{g}/user-flairs"), + ( + lambda: c.create_user_flair( + "general", label="n", background_color="#1f2937", text_color="#ffffff", mod_only=True + ), + "POST", + f"{g}/user-flairs", + ), + (lambda: c.delete_user_flair("general", "t"), "DELETE", f"{g}/user-flairs/t"), + (lambda: c.assign_member_flair("general", "u", template_id="t"), "PUT", f"{g}/members/u/flair"), + (lambda: c.clear_member_flair("general", "u"), "DELETE", f"{g}/members/u/flair"), + (lambda: c.list_removal_reasons("general"), "GET", f"{g}/removal-reasons"), + (lambda: c.create_removal_reason("general", label="l", body="b"), "POST", f"{g}/removal-reasons"), + (lambda: c.delete_removal_reason("general", "r"), "DELETE", f"{g}/removal-reasons/r"), + (lambda: c.list_member_notes("general", "u"), "GET", f"{g}/members/u/notes"), + (lambda: c.add_member_note("general", "u", body="b"), "POST", f"{g}/members/u/notes"), + (lambda: c.delete_member_note("general", "u", "n"), "DELETE", f"{g}/members/u/notes/n"), + ] + for factory, method, path in cases: + await factory() + assert captured[-1].method == method, path + assert captured[-1].url.path == path + + +# ── Mock client (full coverage) ──────────────────────────────────── + + +class TestMockConfig: + def test_every_mock_method_records(self) -> None: + from colony_sdk.testing import MockColonyClient + + m = MockColonyClient() + calls = [ + ("list_post_flairs", lambda: m.list_post_flairs("general")), + ("create_post_flair", lambda: m.create_post_flair("general", label="n")), + ("delete_post_flair", lambda: m.delete_post_flair("general", "f")), + ("list_user_flairs", lambda: m.list_user_flairs("general")), + ("create_user_flair", lambda: m.create_user_flair("general", label="n", mod_only=True)), + ("delete_user_flair", lambda: m.delete_user_flair("general", "t")), + ("assign_member_flair", lambda: m.assign_member_flair("general", "u", template_id="t")), + ("clear_member_flair", lambda: m.clear_member_flair("general", "u")), + ("list_removal_reasons", lambda: m.list_removal_reasons("general")), + ("create_removal_reason", lambda: m.create_removal_reason("general", label="l", body="b")), + ("delete_removal_reason", lambda: m.delete_removal_reason("general", "r")), + ("list_member_notes", lambda: m.list_member_notes("general", "u")), + ("add_member_note", lambda: m.add_member_note("general", "u", body="b")), + ("delete_member_note", lambda: m.delete_member_note("general", "u", "n")), + ] + for name, fn in calls: + assert fn() == {} + assert m.calls[-1][0] == name + assert len(calls) == 14