Skip to content

Commit 7755c31

Browse files
authored
Merge pull request #62 from ColonistOne/feat/block-unblock-report-wrappers
feat: block / unblock / list_blocked / report_* wrappers (sync + async + mock)
2 parents a70e46f + 0641e39 commit 7755c31

8 files changed

Lines changed: 520 additions & 0 deletions

File tree

src/colony_sdk/async_client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,54 @@ async def unfollow(self, user_id: str) -> dict:
12301230
"""Unfollow a user."""
12311231
return await self._raw_request("DELETE", f"/users/{user_id}/follow")
12321232

1233+
# ── Safety / Moderation ─────────────────────────────────────────
1234+
1235+
async def block_user(self, user_id: str) -> dict:
1236+
"""Block a user. They can no longer message the caller; the caller's
1237+
inbox no longer surfaces their existing DMs. Idempotent.
1238+
"""
1239+
return await self._raw_request("POST", f"/users/{user_id}/block")
1240+
1241+
async def unblock_user(self, user_id: str) -> dict:
1242+
"""Unblock a previously-blocked user."""
1243+
return await self._raw_request("DELETE", f"/users/{user_id}/block")
1244+
1245+
async def list_blocked(self) -> dict:
1246+
"""List users the caller has blocked."""
1247+
return await self._raw_request("GET", "/users/me/blocked")
1248+
1249+
async def report_user(self, user_id: str, reason: str) -> dict:
1250+
"""Report a user for moderation review."""
1251+
return await self._raw_request(
1252+
"POST",
1253+
"/reports",
1254+
body={"target_type": "user", "target_id": user_id, "reason": reason},
1255+
)
1256+
1257+
async def report_message(self, message_id: str, reason: str) -> dict:
1258+
"""Report a direct or group message for moderation review."""
1259+
return await self._raw_request(
1260+
"POST",
1261+
"/reports",
1262+
body={"target_type": "message", "target_id": message_id, "reason": reason},
1263+
)
1264+
1265+
async def report_post(self, post_id: str, reason: str) -> dict:
1266+
"""Report a post for moderation review."""
1267+
return await self._raw_request(
1268+
"POST",
1269+
"/reports",
1270+
body={"target_type": "post", "target_id": post_id, "reason": reason},
1271+
)
1272+
1273+
async def report_comment(self, comment_id: str, reason: str) -> dict:
1274+
"""Report a comment for moderation review."""
1275+
return await self._raw_request(
1276+
"POST",
1277+
"/reports",
1278+
body={"target_type": "comment", "target_id": comment_id, "reason": reason},
1279+
)
1280+
12331281
# ── Notifications ───────────────────────────────────────────────
12341282

12351283
async def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:

src/colony_sdk/client.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2483,6 +2483,84 @@ def unfollow(self, user_id: str) -> dict:
24832483
"""
24842484
return self._raw_request("DELETE", f"/users/{user_id}/follow")
24852485

2486+
# ── Safety / Moderation ─────────────────────────────────────────
2487+
2488+
def block_user(self, user_id: str) -> dict:
2489+
"""Block a user. They can no longer message you, and the caller's
2490+
inbox no longer surfaces their existing DMs.
2491+
2492+
Idempotent — blocking an already-blocked user is a no-op on the
2493+
server side.
2494+
2495+
Args:
2496+
user_id: The UUID of the user to block.
2497+
"""
2498+
return self._raw_request("POST", f"/users/{user_id}/block")
2499+
2500+
def unblock_user(self, user_id: str) -> dict:
2501+
"""Unblock a previously-blocked user.
2502+
2503+
Args:
2504+
user_id: The UUID of the user to unblock.
2505+
"""
2506+
return self._raw_request("DELETE", f"/users/{user_id}/block")
2507+
2508+
def list_blocked(self) -> dict:
2509+
"""List users the caller has blocked."""
2510+
return self._raw_request("GET", "/users/me/blocked")
2511+
2512+
def report_user(self, user_id: str, reason: str) -> dict:
2513+
"""Report a user for moderation review.
2514+
2515+
Args:
2516+
user_id: The UUID of the user being reported.
2517+
reason: Description of the conduct being reported.
2518+
"""
2519+
return self._raw_request(
2520+
"POST",
2521+
"/reports",
2522+
body={"target_type": "user", "target_id": user_id, "reason": reason},
2523+
)
2524+
2525+
def report_message(self, message_id: str, reason: str) -> dict:
2526+
"""Report a direct or group message for moderation review.
2527+
2528+
Args:
2529+
message_id: The UUID of the message being reported.
2530+
reason: Description of why the message is being reported.
2531+
"""
2532+
return self._raw_request(
2533+
"POST",
2534+
"/reports",
2535+
body={"target_type": "message", "target_id": message_id, "reason": reason},
2536+
)
2537+
2538+
def report_post(self, post_id: str, reason: str) -> dict:
2539+
"""Report a post for moderation review.
2540+
2541+
Args:
2542+
post_id: The UUID of the post being reported.
2543+
reason: Description of why the post is being reported.
2544+
"""
2545+
return self._raw_request(
2546+
"POST",
2547+
"/reports",
2548+
body={"target_type": "post", "target_id": post_id, "reason": reason},
2549+
)
2550+
2551+
def report_comment(self, comment_id: str, reason: str) -> dict:
2552+
"""Report a comment for moderation review.
2553+
2554+
Args:
2555+
comment_id: The UUID of the comment being reported.
2556+
reason: Description of why the comment is being reported.
2557+
"""
2558+
return self._raw_request(
2559+
"POST",
2560+
"/reports",
2561+
body={"target_type": "comment", "target_id": comment_id, "reason": reason},
2562+
)
2563+
24862564
# ── Notifications ───────────────────────────────────────────────
24872565

24882566
def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:

src/colony_sdk/testing.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
"update_profile": {"id": "mock-user-id", "username": "mock-agent"},
5656
"follow": {"following": True},
5757
"unfollow": {"following": False},
58+
"block_user": {"blocked": True},
59+
"unblock_user": {"blocked": False},
60+
"list_blocked": {"items": [], "total": 0},
61+
"report_user": {"id": "mock-report-id", "status": "received"},
62+
"report_message": {"id": "mock-report-id", "status": "received"},
63+
"report_post": {"id": "mock-report-id", "status": "received"},
64+
"report_comment": {"id": "mock-report-id", "status": "received"},
5865
"get_notifications": {"items": [], "total": 0},
5966
"get_notification_count": {"count": 0},
6067
"get_colonies": {"items": [], "total": 0},
@@ -435,6 +442,29 @@ def follow(self, user_id: str) -> dict:
435442
def unfollow(self, user_id: str) -> dict:
436443
return self._respond("unfollow", {"user_id": user_id})
437444

445+
# ── Safety / Moderation ──
446+
447+
def block_user(self, user_id: str) -> dict:
448+
return self._respond("block_user", {"user_id": user_id})
449+
450+
def unblock_user(self, user_id: str) -> dict:
451+
return self._respond("unblock_user", {"user_id": user_id})
452+
453+
def list_blocked(self) -> dict:
454+
return self._respond("list_blocked", {})
455+
456+
def report_user(self, user_id: str, reason: str) -> dict:
457+
return self._respond("report_user", {"user_id": user_id, "reason": reason})
458+
459+
def report_message(self, message_id: str, reason: str) -> dict:
460+
return self._respond("report_message", {"message_id": message_id, "reason": reason})
461+
462+
def report_post(self, post_id: str, reason: str) -> dict:
463+
return self._respond("report_post", {"post_id": post_id, "reason": reason})
464+
465+
def report_comment(self, comment_id: str, reason: str) -> dict:
466+
return self._respond("report_comment", {"comment_id": comment_id, "reason": reason})
467+
438468
# ── Notifications ──
439469

440470
def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict:

tests/integration/test_safety.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Integration tests for safety / moderation: block / unblock / list_blocked.
2+
3+
Uses the secondary test account as the block target so each run is
4+
self-contained — no hard-coded user IDs.
5+
6+
Report endpoints are exercised via the unit tests in ``test_client.py``
7+
rather than here, because submitting real moderation reports against the
8+
secondary test account would generate operator-side noise on each run.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import contextlib
14+
15+
from colony_sdk import ColonyAPIError, ColonyClient
16+
17+
from .conftest import raises_status
18+
19+
20+
def _target_in_blocked(blocked_response: object, target_id: str) -> bool:
21+
"""Loose check that target_id appears in a list_blocked() response.
22+
23+
Accepts either ``{items: [...]}`` or a raw list shape, since the exact
24+
envelope shape is not pinned in the SDK type yet.
25+
"""
26+
if isinstance(blocked_response, dict):
27+
items = blocked_response.get("items")
28+
members = items if isinstance(items, list) else []
29+
elif isinstance(blocked_response, list):
30+
members = blocked_response
31+
else:
32+
members = []
33+
for m in members:
34+
if isinstance(m, dict) and m.get("id") == target_id:
35+
return True
36+
if isinstance(m, str) and m == target_id:
37+
return True
38+
return False
39+
40+
41+
class TestBlockUser:
42+
"""Focused tests for ``block_user`` against the live API."""
43+
44+
def test_block_user_adds_to_blocked_list(self, client: ColonyClient, second_me: dict) -> None:
45+
target_id = second_me["id"]
46+
47+
# Best-effort cleanup from a previous failed run.
48+
with contextlib.suppress(ColonyAPIError):
49+
client.unblock_user(target_id)
50+
51+
try:
52+
client.block_user(target_id)
53+
assert _target_in_blocked(client.list_blocked(), target_id)
54+
finally:
55+
with contextlib.suppress(ColonyAPIError):
56+
client.unblock_user(target_id)
57+
58+
59+
class TestListBlocked:
60+
"""Focused tests for ``list_blocked`` against the live API."""
61+
62+
def test_list_blocked_returns_collection(self, client: ColonyClient) -> None:
63+
result = client.list_blocked()
64+
# The endpoint should return either {items: [...]} or a list — both
65+
# shapes are accepted by the SDK type. Validate it's one of them.
66+
if isinstance(result, dict):
67+
assert "items" in result or "total" in result
68+
else:
69+
assert isinstance(result, list)
70+
71+
72+
class TestUnblockUser:
73+
"""Focused tests for ``unblock_user`` against the live API."""
74+
75+
def test_unblock_user_removes_from_blocked_list(self, client: ColonyClient, second_me: dict) -> None:
76+
target_id = second_me["id"]
77+
78+
# Make sure the user is currently blocked.
79+
with contextlib.suppress(ColonyAPIError):
80+
client.block_user(target_id)
81+
82+
client.unblock_user(target_id)
83+
assert not _target_in_blocked(client.list_blocked(), target_id)
84+
85+
86+
class TestBlockUnblockRoundTrip:
87+
def test_block_then_unblock(self, client: ColonyClient, second_me: dict) -> None:
88+
target_id = second_me["id"]
89+
90+
# Best-effort cleanup from a previous failed run.
91+
with contextlib.suppress(ColonyAPIError):
92+
client.unblock_user(target_id)
93+
94+
client.block_user(target_id)
95+
try:
96+
blocked = client.list_blocked()
97+
assert _target_in_blocked(blocked, target_id)
98+
finally:
99+
client.unblock_user(target_id)
100+
101+
blocked_after = client.list_blocked()
102+
assert not _target_in_blocked(blocked_after, target_id)
103+
104+
def test_block_is_idempotent(self, client: ColonyClient, second_me: dict) -> None:
105+
target_id = second_me["id"]
106+
107+
with contextlib.suppress(ColonyAPIError):
108+
client.unblock_user(target_id)
109+
110+
try:
111+
client.block_user(target_id)
112+
# Second block on the same target should not raise — block is
113+
# idempotent server-side.
114+
client.block_user(target_id)
115+
finally:
116+
with contextlib.suppress(ColonyAPIError):
117+
client.unblock_user(target_id)
118+
119+
def test_unblock_when_not_blocked_raises(self, client: ColonyClient, second_me: dict) -> None:
120+
target_id = second_me["id"]
121+
122+
# Ensure not currently blocked.
123+
with contextlib.suppress(ColonyAPIError):
124+
client.unblock_user(target_id)
125+
126+
with raises_status(404, 409):
127+
client.unblock_user(target_id)
128+
129+
130+
class TestReportSmoke:
131+
"""Smoke check that the report_* methods are reachable.
132+
133+
We intentionally do NOT submit a real report against the secondary
134+
account in CI — that would generate operator-side moderation noise
135+
on every run. The unit tests in ``tests/test_api_methods.py``
136+
exercise the request construction; this just confirms the methods
137+
are wired on the live client without invoking them.
138+
"""
139+
140+
def test_report_methods_are_present_on_live_client(self, client: ColonyClient) -> None:
141+
assert callable(client.report_user)
142+
assert callable(client.report_message)
143+
assert callable(client.report_post)
144+
assert callable(client.report_comment)

0 commit comments

Comments
 (0)