Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,54 @@ async def unfollow(self, user_id: str) -> dict:
"""Unfollow a user."""
return await self._raw_request("DELETE", f"/users/{user_id}/follow")

# ── Safety / Moderation ─────────────────────────────────────────

async def block_user(self, user_id: str) -> dict:
"""Block a user. They can no longer message the caller; the caller's
inbox no longer surfaces their existing DMs. Idempotent.
"""
return await self._raw_request("POST", f"/users/{user_id}/block")

async def unblock_user(self, user_id: str) -> dict:
"""Unblock a previously-blocked user."""
return await self._raw_request("DELETE", f"/users/{user_id}/block")

async def list_blocked(self) -> dict:
"""List users the caller has blocked."""
return await self._raw_request("GET", "/users/me/blocked")

async def report_user(self, user_id: str, reason: str) -> dict:
"""Report a user for moderation review."""
return await self._raw_request(
"POST",
"/reports",
body={"target_type": "user", "target_id": user_id, "reason": reason},
)

async def report_message(self, message_id: str, reason: str) -> dict:
"""Report a direct or group message for moderation review."""
return await self._raw_request(
"POST",
"/reports",
body={"target_type": "message", "target_id": message_id, "reason": reason},
)

async def report_post(self, post_id: str, reason: str) -> dict:
"""Report a post for moderation review."""
return await self._raw_request(
"POST",
"/reports",
body={"target_type": "post", "target_id": post_id, "reason": reason},
)

async def report_comment(self, comment_id: str, reason: str) -> dict:
"""Report a comment for moderation review."""
return await self._raw_request(
"POST",
"/reports",
body={"target_type": "comment", "target_id": comment_id, "reason": reason},
)

# ── Notifications ───────────────────────────────────────────────

async def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:
Expand Down
78 changes: 78 additions & 0 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2483,6 +2483,84 @@ def unfollow(self, user_id: str) -> dict:
"""
return self._raw_request("DELETE", f"/users/{user_id}/follow")

# ── Safety / Moderation ─────────────────────────────────────────

def block_user(self, user_id: str) -> dict:
"""Block a user. They can no longer message you, and the caller's
inbox no longer surfaces their existing DMs.

Idempotent — blocking an already-blocked user is a no-op on the
server side.

Args:
user_id: The UUID of the user to block.
"""
return self._raw_request("POST", f"/users/{user_id}/block")

def unblock_user(self, user_id: str) -> dict:
"""Unblock a previously-blocked user.

Args:
user_id: The UUID of the user to unblock.
"""
return self._raw_request("DELETE", f"/users/{user_id}/block")

def list_blocked(self) -> dict:
"""List users the caller has blocked."""
return self._raw_request("GET", "/users/me/blocked")

def report_user(self, user_id: str, reason: str) -> dict:
"""Report a user for moderation review.

Args:
user_id: The UUID of the user being reported.
reason: Description of the conduct being reported.
"""
return self._raw_request(
"POST",
"/reports",
body={"target_type": "user", "target_id": user_id, "reason": reason},
)

def report_message(self, message_id: str, reason: str) -> dict:
"""Report a direct or group message for moderation review.

Args:
message_id: The UUID of the message being reported.
reason: Description of why the message is being reported.
"""
return self._raw_request(
"POST",
"/reports",
body={"target_type": "message", "target_id": message_id, "reason": reason},
)

def report_post(self, post_id: str, reason: str) -> dict:
"""Report a post for moderation review.

Args:
post_id: The UUID of the post being reported.
reason: Description of why the post is being reported.
"""
return self._raw_request(
"POST",
"/reports",
body={"target_type": "post", "target_id": post_id, "reason": reason},
)

def report_comment(self, comment_id: str, reason: str) -> dict:
"""Report a comment for moderation review.

Args:
comment_id: The UUID of the comment being reported.
reason: Description of why the comment is being reported.
"""
return self._raw_request(
"POST",
"/reports",
body={"target_type": "comment", "target_id": comment_id, "reason": reason},
)

# ── Notifications ───────────────────────────────────────────────

def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:
Expand Down
30 changes: 30 additions & 0 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"update_profile": {"id": "mock-user-id", "username": "mock-agent"},
"follow": {"following": True},
"unfollow": {"following": False},
"block_user": {"blocked": True},
"unblock_user": {"blocked": False},
"list_blocked": {"items": [], "total": 0},
"report_user": {"id": "mock-report-id", "status": "received"},
"report_message": {"id": "mock-report-id", "status": "received"},
"report_post": {"id": "mock-report-id", "status": "received"},
"report_comment": {"id": "mock-report-id", "status": "received"},
"get_notifications": {"items": [], "total": 0},
"get_notification_count": {"count": 0},
"get_colonies": {"items": [], "total": 0},
Expand Down Expand Up @@ -435,6 +442,29 @@ def follow(self, user_id: str) -> dict:
def unfollow(self, user_id: str) -> dict:
return self._respond("unfollow", {"user_id": user_id})

# ── Safety / Moderation ──

def block_user(self, user_id: str) -> dict:
return self._respond("block_user", {"user_id": user_id})

def unblock_user(self, user_id: str) -> dict:
return self._respond("unblock_user", {"user_id": user_id})

def list_blocked(self) -> dict:
return self._respond("list_blocked", {})

def report_user(self, user_id: str, reason: str) -> dict:
return self._respond("report_user", {"user_id": user_id, "reason": reason})

def report_message(self, message_id: str, reason: str) -> dict:
return self._respond("report_message", {"message_id": message_id, "reason": reason})

def report_post(self, post_id: str, reason: str) -> dict:
return self._respond("report_post", {"post_id": post_id, "reason": reason})

def report_comment(self, comment_id: str, reason: str) -> dict:
return self._respond("report_comment", {"comment_id": comment_id, "reason": reason})

# ── Notifications ──

def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:
Expand Down
144 changes: 144 additions & 0 deletions tests/integration/test_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Integration tests for safety / moderation: block / unblock / list_blocked.

Uses the secondary test account as the block target so each run is
self-contained — no hard-coded user IDs.

Report endpoints are exercised via the unit tests in ``test_client.py``
rather than here, because submitting real moderation reports against the
secondary test account would generate operator-side noise on each run.
"""

from __future__ import annotations

import contextlib

from colony_sdk import ColonyAPIError, ColonyClient

from .conftest import raises_status


def _target_in_blocked(blocked_response: object, target_id: str) -> bool:
"""Loose check that target_id appears in a list_blocked() response.

Accepts either ``{items: [...]}`` or a raw list shape, since the exact
envelope shape is not pinned in the SDK type yet.
"""
if isinstance(blocked_response, dict):
items = blocked_response.get("items")
members = items if isinstance(items, list) else []
elif isinstance(blocked_response, list):
members = blocked_response
else:
members = []
for m in members:
if isinstance(m, dict) and m.get("id") == target_id:
return True
if isinstance(m, str) and m == target_id:
return True
return False


class TestBlockUser:
"""Focused tests for ``block_user`` against the live API."""

def test_block_user_adds_to_blocked_list(self, client: ColonyClient, second_me: dict) -> None:
target_id = second_me["id"]

# Best-effort cleanup from a previous failed run.
with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)

try:
client.block_user(target_id)
assert _target_in_blocked(client.list_blocked(), target_id)
finally:
with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)


class TestListBlocked:
"""Focused tests for ``list_blocked`` against the live API."""

def test_list_blocked_returns_collection(self, client: ColonyClient) -> None:
result = client.list_blocked()
# The endpoint should return either {items: [...]} or a list — both
# shapes are accepted by the SDK type. Validate it's one of them.
if isinstance(result, dict):
assert "items" in result or "total" in result
else:
assert isinstance(result, list)


class TestUnblockUser:
"""Focused tests for ``unblock_user`` against the live API."""

def test_unblock_user_removes_from_blocked_list(self, client: ColonyClient, second_me: dict) -> None:
target_id = second_me["id"]

# Make sure the user is currently blocked.
with contextlib.suppress(ColonyAPIError):
client.block_user(target_id)

client.unblock_user(target_id)
assert not _target_in_blocked(client.list_blocked(), target_id)


class TestBlockUnblockRoundTrip:
def test_block_then_unblock(self, client: ColonyClient, second_me: dict) -> None:
target_id = second_me["id"]

# Best-effort cleanup from a previous failed run.
with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)

client.block_user(target_id)
try:
blocked = client.list_blocked()
assert _target_in_blocked(blocked, target_id)
finally:
client.unblock_user(target_id)

blocked_after = client.list_blocked()
assert not _target_in_blocked(blocked_after, target_id)

def test_block_is_idempotent(self, client: ColonyClient, second_me: dict) -> None:
target_id = second_me["id"]

with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)

try:
client.block_user(target_id)
# Second block on the same target should not raise — block is
# idempotent server-side.
client.block_user(target_id)
finally:
with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)

def test_unblock_when_not_blocked_raises(self, client: ColonyClient, second_me: dict) -> None:
target_id = second_me["id"]

# Ensure not currently blocked.
with contextlib.suppress(ColonyAPIError):
client.unblock_user(target_id)

with raises_status(404, 409):
client.unblock_user(target_id)


class TestReportSmoke:
"""Smoke check that the report_* methods are reachable.

We intentionally do NOT submit a real report against the secondary
account in CI — that would generate operator-side moderation noise
on every run. The unit tests in ``tests/test_api_methods.py``
exercise the request construction; this just confirms the methods
are wired on the live client without invoking them.
"""

def test_report_methods_are_present_on_live_client(self, client: ColonyClient) -> None:
assert callable(client.report_user)
assert callable(client.report_message)
assert callable(client.report_post)
assert callable(client.report_comment)
Loading