From c3f083dd584992cb944e25f979c941a0c916ad12 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 27 May 2026 14:59:55 +0100 Subject: [PATCH 1/2] test: integration coverage for basic group-DM lifecycle Adds tests/integration/test_group_messages.py covering the round trip across two real test accounts: - create_group_conversation (primary invites secondary) - get_group_conversation visibility from creator + invitee - respond_to_group_invite (accept + decline branches) - send_group_message round trip (both directions after accept) - list_group_members consistency across both sides - mark_group_all_read Module-scoped accepted_group fixture keeps create-group calls down for the rate-limit budget; per-test pending_group fixture is function-scoped so accept/decline tests get clean rooms. Karma gate is handled the same way as test_messages.py: tests skip cleanly if the sending account is below the 5-karma threshold. Also extends tests/integration/README.md so the new file shows up in the test-scope table. Co-Authored-By: Claude Opus 4.7 --- tests/integration/README.md | 1 + tests/integration/test_group_messages.py | 236 +++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/integration/test_group_messages.py diff --git a/tests/integration/README.md b/tests/integration/README.md index 9fa21eb..05aef5b 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -51,6 +51,7 @@ pytest -m "not integration" | `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_group_messages.py` | `create_group_conversation`, `respond_to_group_invite` (accept + decline), `send_group_message`, `list_group_members`, `mark_group_all_read` round trip from both sides | | `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 | diff --git a/tests/integration/test_group_messages.py b/tests/integration/test_group_messages.py new file mode 100644 index 0000000..8a9728c --- /dev/null +++ b/tests/integration/test_group_messages.py @@ -0,0 +1,236 @@ +"""Integration tests for group-DM lifecycle. + +Covers the basic round trip: + +1. Primary creates a group conversation with the secondary as an invitee +2. Secondary sees the pending invite via :meth:`get_group_conversation` +3. Secondary accepts (or declines) the invite +4. After acceptance, either side can send messages and read the room +5. List membership from both sides +6. Mark all read + +Like :mod:`test_messages`, every test in this file requires +``COLONY_TEST_API_KEY_2`` and at least ``MIN_KARMA_FOR_DM`` karma on +the sending account — the server runs the same DM-eligibility check +(block / privacy / karma gate) against each group invitee. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator + +import pytest + +from colony_sdk import ColonyAPIError, ColonyAuthError, ColonyClient + +from .conftest import 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 invite to groups. " + "Have other agents upvote the test account's posts to bootstrap." + ) + + +def _create_or_skip( + creator: ColonyClient, + title: str, + members: list[str], +) -> dict: + """Create a group, skipping cleanly on karma / eligibility 403.""" + try: + return creator.create_group_conversation(title=title, members=members) + except ColonyAuthError as e: + if "karma" in str(e).lower() or "eligibility" in str(e).lower(): + pytest.skip(f"DM eligibility gate: {e}") + raise + + +# ── Fixtures ──────────────────────────────────────────────────────────── + + +@pytest.fixture +def pending_group( + client: ColonyClient, + second_me: dict, + me: dict, +) -> Iterator[dict]: + """Fresh group created by primary with secondary as a pending invitee. + + Function-scoped so each test that wants to manipulate the invite + state (accept / decline / inspect "pending") gets a clean room. + """ + _skip_if_low_karma(me) + + title = f"sdk-it group pending {unique_suffix()}" + group = _create_or_skip(client, title=title, members=[second_me["username"]]) + + try: + yield group + finally: + # Best-effort cleanup: remove the invitee so the group becomes + # effectively dormant from the secondary's view. There's no + # delete_group_conversation endpoint — the row will persist on + # the primary's side, which is fine (test account, hidden from + # listings via is_tester). + with contextlib.suppress(ColonyAPIError): + client.remove_group_member(group["id"], second_me["id"]) + + +@pytest.fixture(scope="module") +def accepted_group( + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, +) -> Iterator[dict]: + """Module-scoped group where the secondary has accepted the invite. + + Created once and reused by every test that just needs a live + accepted group (send, list members, mark-all-read). Module scope + keeps the create-group call count down: rate-limit budgets are + shared across the whole integration suite. + """ + _skip_if_low_karma(me) + + title = f"sdk-it group accepted {unique_suffix()}" + group = _create_or_skip(client, title=title, members=[second_me["username"]]) + + # Secondary accepts the invite up front so the group is usable. + try: + response = second_client.respond_to_group_invite(group["id"], accept=True) + except ColonyAPIError as e: + pytest.skip(f"secondary could not accept group invite: {e}") + assert response.get("status") == "accepted", response + + try: + yield group + finally: + with contextlib.suppress(ColonyAPIError): + client.remove_group_member(group["id"], second_me["id"]) + + +# ── Tests ─────────────────────────────────────────────────────────────── + + +class TestGroupConversationLifecycle: + def test_create_group_visible_to_creator( + self, + client: ColonyClient, + pending_group: dict, + ) -> None: + """The creator can fetch their freshly-created group.""" + fetched = client.get_group_conversation(pending_group["id"]) + assert fetched["id"] == pending_group["id"] + assert fetched.get("is_group") is True + # The creator should not be a pending invitee on their own group. + assert fetched.get("my_invite_status") in (None, "accepted"), fetched.get("my_invite_status") + + def test_invitee_sees_pending_invite_status( + self, + second_client: ColonyClient, + pending_group: dict, + ) -> None: + """Before responding, the invitee's row reads ``pending``.""" + fetched = second_client.get_group_conversation(pending_group["id"]) + assert fetched["id"] == pending_group["id"] + assert fetched.get("my_invite_status") == "pending", fetched.get("my_invite_status") + + def test_decline_invite_removes_membership( + self, + second_client: ColonyClient, + pending_group: dict, + ) -> None: + """Declining flips the row to ``declined`` and the invitee loses access.""" + response = second_client.respond_to_group_invite(pending_group["id"], accept=False) + assert response.get("status") == "declined", response + + # After decline, the secondary is no longer a member — the group + # 403s or 404s. Either is correct contract per the docstring. + with pytest.raises(ColonyAPIError) as exc: + second_client.get_group_conversation(pending_group["id"]) + assert exc.value.status in (403, 404), exc.value.status + + +class TestGroupMessaging: + def test_accepted_group_status_is_accepted_for_invitee( + self, + second_client: ColonyClient, + accepted_group: dict, + ) -> None: + """Sanity check: the module fixture really did accept.""" + fetched = second_client.get_group_conversation(accepted_group["id"]) + assert fetched.get("my_invite_status") == "accepted", fetched.get("my_invite_status") + + def test_list_group_members_from_both_sides( + self, + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, + accepted_group: dict, + ) -> None: + """Both participants can list members; both users appear.""" + from_primary = client.list_group_members(accepted_group["id"]) + from_secondary = second_client.list_group_members(accepted_group["id"]) + + primary_usernames = {m["username"] for m in from_primary.get("members", [])} + secondary_usernames = {m["username"] for m in from_secondary.get("members", [])} + + assert me["username"] in primary_usernames + assert second_me["username"] in primary_usernames + assert primary_usernames == secondary_usernames, "creator and invitee see different membership rosters" + + def test_send_group_message_round_trip( + self, + client: ColonyClient, + second_client: ColonyClient, + accepted_group: dict, + ) -> None: + """Primary sends → secondary's get_group_conversation sees the body.""" + body = f"group round-trip {unique_suffix()}" + sent = client.send_group_message(accepted_group["id"], body) + assert isinstance(sent, dict) + assert sent.get("body") == body or sent.get("id"), sent + + fetched = second_client.get_group_conversation(accepted_group["id"], limit=20) + messages = fetched.get("messages", []) + bodies = [m.get("body") for m in messages] + assert body in bodies, f"sent body not visible to invitee; got {bodies}" + + def test_secondary_can_send_after_accepting( + self, + client: ColonyClient, + second_client: ColonyClient, + accepted_group: dict, + ) -> None: + """Once accepted, the invitee can also post into the room.""" + body = f"group reply {unique_suffix()}" + sent = second_client.send_group_message(accepted_group["id"], body) + assert isinstance(sent, dict) + + fetched = client.get_group_conversation(accepted_group["id"], limit=20) + bodies = [m.get("body") for m in fetched.get("messages", [])] + assert body in bodies, f"secondary's message not visible to creator; got {bodies}" + + def test_mark_group_all_read( + self, + client: ColonyClient, + second_client: ColonyClient, + accepted_group: dict, + ) -> None: + """After a fresh message, the recipient can bulk-mark the room read.""" + client.send_group_message(accepted_group["id"], f"unread probe {unique_suffix()}") + + result = second_client.mark_group_all_read(accepted_group["id"]) + assert isinstance(result, dict) + # Endpoint returns ``{marked_read: int}``; accept any int including 0 + # (the message may already have been auto-marked by a prior fetch). + marked = result.get("marked_read") + assert isinstance(marked, int) and marked >= 0, result From ce62ef0e51931edec2c16b08645442ba97a9ae5a Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 27 May 2026 15:11:12 +0100 Subject: [PATCH 2/2] test: align group-DM integration tests with live server shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live API run against the integration-tester / integration-tester-2 account pair surfaced three places where the docstring shape didn't match server reality: 1. get_group_conversation returns a slim envelope: {id, title, description, creator_id, member_count, messages, pinned}. No is_group, no my_invite_status, no members[]. Membership lives on the dedicated list_group_members endpoint. 2. Invites between these two accounts auto-accept on creation — respond_to_group_invite returns 400 "Invite is not pending". Likely a trust-level / follow gate bypass for accounts with prior DM history. 3. mark_group_all_read returns {marked: int}, not {marked_read: int}. Rewrites the test file to: - Drop assertions on get_group_conversation fields that don't exist (is_group, my_invite_status); assert on what's actually returned (id, title, member_count). - Replace the pending → accept → decline lifecycle tests with a single TestGroupInviteAcceptPath::test_accept_invite_when_pending that probes for a pending invite and skips with a clear reason when the server auto-accepted. A future run against accounts with no trust relationship will cover the accept path automatically. - Use the module-scoped `group` fixture with a defensive contextlib.suppress(ColonyAPIError) around the accept call, so it works whether the invite was pending or auto-accepted. - Accept either `marked` or `marked_read` in test_mark_group_all_read. - Document the observed-vs-docstring divergence in the module docstring so the SDK author can decide whether to update the client docstrings. Live run: 7 passed, 1 skipped (accept path). Co-Authored-By: Claude Opus 4.7 --- tests/integration/test_group_messages.py | 282 +++++++++++++---------- 1 file changed, 157 insertions(+), 125 deletions(-) diff --git a/tests/integration/test_group_messages.py b/tests/integration/test_group_messages.py index 8a9728c..4d1b400 100644 --- a/tests/integration/test_group_messages.py +++ b/tests/integration/test_group_messages.py @@ -1,18 +1,45 @@ -"""Integration tests for group-DM lifecycle. +"""Integration tests for the basic group-DM round trip. -Covers the basic round trip: +Covers: -1. Primary creates a group conversation with the secondary as an invitee -2. Secondary sees the pending invite via :meth:`get_group_conversation` -3. Secondary accepts (or declines) the invite -4. After acceptance, either side can send messages and read the room -5. List membership from both sides -6. Mark all read +1. Primary creates a group conversation with the secondary as a member +2. Both sides can list members and see each other +3. Either side can ``send_group_message``; the other side reads it back +4. Either side can ``mark_group_all_read`` +5. Best-effort ``respond_to_group_invite(accept=True)`` exercise — see + "Invite lifecycle" below Like :mod:`test_messages`, every test in this file requires ``COLONY_TEST_API_KEY_2`` and at least ``MIN_KARMA_FOR_DM`` karma on the sending account — the server runs the same DM-eligibility check (block / privacy / karma gate) against each group invitee. + +## Invite lifecycle (observed against the live API) + +Empirically against the integration-tester / integration-tester-2 +account pair (both at trust level "Member", 10+ karma), the server +**auto-accepts** a fresh group invite at creation time — the secondary +becomes a full participant immediately and ``respond_to_group_invite`` +returns 400 "Invite is not pending". So the explicit pending → accept +path can't be reliably exercised from these accounts. + +:class:`TestGroupInviteAcceptPath` *probes* whether the secondary's +invite is pending and either exercises the accept call or skips with +a clear reason. If you re-run this suite against a pair of accounts +with no trust relationship between them, that test should exercise +the accept path instead of skipping. + +The decline path is not covered for the same reason. + +## Server-response shape notes + +- ``get_group_conversation(conv_id)`` returns a slim envelope: + ``{id, title, description, creator_id, member_count, messages, + pinned}``. Notably absent vs the docstring: ``is_group``, + ``my_invite_status``, ``my_role``, ``members``. Member listing is + via the dedicated ``list_group_members`` endpoint. +- ``mark_group_all_read`` returns ``{marked: int}`` (not + ``{marked_read: int}`` as the docstring suggests). """ from __future__ import annotations @@ -38,11 +65,7 @@ def _skip_if_low_karma(profile: dict) -> None: ) -def _create_or_skip( - creator: ColonyClient, - title: str, - members: list[str], -) -> dict: +def _create_or_skip(creator: ColonyClient, title: str, members: list[str]) -> dict: """Create a group, skipping cleanly on karma / eligibility 403.""" try: return creator.create_group_conversation(title=title, members=members) @@ -55,130 +78,98 @@ def _create_or_skip( # ── Fixtures ──────────────────────────────────────────────────────────── -@pytest.fixture -def pending_group( - client: ColonyClient, - second_me: dict, - me: dict, -) -> Iterator[dict]: - """Fresh group created by primary with secondary as a pending invitee. - - Function-scoped so each test that wants to manipulate the invite - state (accept / decline / inspect "pending") gets a clean room. - """ - _skip_if_low_karma(me) - - title = f"sdk-it group pending {unique_suffix()}" - group = _create_or_skip(client, title=title, members=[second_me["username"]]) - - try: - yield group - finally: - # Best-effort cleanup: remove the invitee so the group becomes - # effectively dormant from the secondary's view. There's no - # delete_group_conversation endpoint — the row will persist on - # the primary's side, which is fine (test account, hidden from - # listings via is_tester). - with contextlib.suppress(ColonyAPIError): - client.remove_group_member(group["id"], second_me["id"]) - - @pytest.fixture(scope="module") -def accepted_group( +def group( client: ColonyClient, second_client: ColonyClient, me: dict, second_me: dict, ) -> Iterator[dict]: - """Module-scoped group where the secondary has accepted the invite. + """Module-scoped group room, primary as creator + secondary as member. + + Created once and reused by every test in the messaging block so the + suite doesn't burn its create-group budget per test. - Created once and reused by every test that just needs a live - accepted group (send, list members, mark-all-read). Module scope - keeps the create-group call count down: rate-limit budgets are - shared across the whole integration suite. + Defensively attempts ``respond_to_group_invite(accept=True)``: when + the server delivered a pending invite, this transitions it; when + the server auto-accepted (see module docstring), the 400 "Invite is + not pending" is suppressed and the room is already usable. """ _skip_if_low_karma(me) - title = f"sdk-it group accepted {unique_suffix()}" - group = _create_or_skip(client, title=title, members=[second_me["username"]]) + title = f"sdk-it group {unique_suffix()}" + g = _create_or_skip(client, title=title, members=[second_me["username"]]) - # Secondary accepts the invite up front so the group is usable. - try: - response = second_client.respond_to_group_invite(group["id"], accept=True) - except ColonyAPIError as e: - pytest.skip(f"secondary could not accept group invite: {e}") - assert response.get("status") == "accepted", response + with contextlib.suppress(ColonyAPIError): + second_client.respond_to_group_invite(g["id"], accept=True) try: - yield group + yield g finally: + # Best-effort teardown: there's no delete_group_conversation + # endpoint, so the group row persists on the primary's side + # either way. Removing the secondary makes it effectively + # dormant from their inbox; the primary's row stays put (these + # are tester accounts whose content is hidden from listings). with contextlib.suppress(ColonyAPIError): - client.remove_group_member(group["id"], second_me["id"]) + client.remove_group_member(g["id"], second_me["id"]) # ── Tests ─────────────────────────────────────────────────────────────── -class TestGroupConversationLifecycle: - def test_create_group_visible_to_creator( +class TestGroupCreation: + def test_create_group_returns_full_envelope( self, client: ColonyClient, - pending_group: dict, - ) -> None: - """The creator can fetch their freshly-created group.""" - fetched = client.get_group_conversation(pending_group["id"]) - assert fetched["id"] == pending_group["id"] - assert fetched.get("is_group") is True - # The creator should not be a pending invitee on their own group. - assert fetched.get("my_invite_status") in (None, "accepted"), fetched.get("my_invite_status") - - def test_invitee_sees_pending_invite_status( - self, - second_client: ColonyClient, - pending_group: dict, + me: dict, + second_me: dict, ) -> None: - """Before responding, the invitee's row reads ``pending``.""" - fetched = second_client.get_group_conversation(pending_group["id"]) - assert fetched["id"] == pending_group["id"] - assert fetched.get("my_invite_status") == "pending", fetched.get("my_invite_status") + """``create_group_conversation`` returns ``{id, title, is_group, creator_id, members}``.""" + _skip_if_low_karma(me) - def test_decline_invite_removes_membership( - self, - second_client: ColonyClient, - pending_group: dict, - ) -> None: - """Declining flips the row to ``declined`` and the invitee loses access.""" - response = second_client.respond_to_group_invite(pending_group["id"], accept=False) - assert response.get("status") == "declined", response + title = f"sdk-it create {unique_suffix()}" + g = _create_or_skip(client, title=title, members=[second_me["username"]]) - # After decline, the secondary is no longer a member — the group - # 403s or 404s. Either is correct contract per the docstring. - with pytest.raises(ColonyAPIError) as exc: - second_client.get_group_conversation(pending_group["id"]) - assert exc.value.status in (403, 404), exc.value.status + try: + assert g["title"] == title + assert g.get("is_group") is True + assert g["creator_id"] == me["id"] + members = g.get("members", []) + usernames = {m["username"] for m in members} + assert me["username"] in usernames + assert second_me["username"] in usernames + finally: + with contextlib.suppress(ColonyAPIError): + client.remove_group_member(g["id"], second_me["id"]) class TestGroupMessaging: - def test_accepted_group_status_is_accepted_for_invitee( - self, - second_client: ColonyClient, - accepted_group: dict, - ) -> None: - """Sanity check: the module fixture really did accept.""" - fetched = second_client.get_group_conversation(accepted_group["id"]) - assert fetched.get("my_invite_status") == "accepted", fetched.get("my_invite_status") - - def test_list_group_members_from_both_sides( + def test_creator_can_fetch_group(self, client: ColonyClient, group: dict) -> None: + """``get_group_conversation`` round-trips the room for the creator.""" + fetched = client.get_group_conversation(group["id"]) + assert fetched["id"] == group["id"] + assert fetched["title"] == group["title"] + # The slim GET envelope reports member_count, not the full member list. + assert fetched.get("member_count") == 2 + + def test_invitee_can_fetch_group(self, second_client: ColonyClient, group: dict) -> None: + """The invitee can also fetch the room (read access works).""" + fetched = second_client.get_group_conversation(group["id"]) + assert fetched["id"] == group["id"] + assert fetched.get("member_count") == 2 + + def test_list_group_members_consistent_across_sides( self, client: ColonyClient, second_client: ColonyClient, me: dict, second_me: dict, - accepted_group: dict, + group: dict, ) -> None: - """Both participants can list members; both users appear.""" - from_primary = client.list_group_members(accepted_group["id"]) - from_secondary = second_client.list_group_members(accepted_group["id"]) + """Both participants see the same membership roster.""" + from_primary = client.list_group_members(group["id"]) + from_secondary = second_client.list_group_members(group["id"]) primary_usernames = {m["username"] for m in from_primary.get("members", [])} secondary_usernames = {m["username"] for m in from_secondary.get("members", [])} @@ -187,35 +178,35 @@ def test_list_group_members_from_both_sides( assert second_me["username"] in primary_usernames assert primary_usernames == secondary_usernames, "creator and invitee see different membership rosters" - def test_send_group_message_round_trip( + def test_send_from_primary_visible_to_secondary( self, client: ColonyClient, second_client: ColonyClient, - accepted_group: dict, + group: dict, ) -> None: - """Primary sends → secondary's get_group_conversation sees the body.""" - body = f"group round-trip {unique_suffix()}" - sent = client.send_group_message(accepted_group["id"], body) + """Primary sends → secondary's ``get_group_conversation`` sees the body.""" + body = f"group probe primary {unique_suffix()}" + sent = client.send_group_message(group["id"], body) assert isinstance(sent, dict) - assert sent.get("body") == body or sent.get("id"), sent + assert sent.get("body") == body - fetched = second_client.get_group_conversation(accepted_group["id"], limit=20) - messages = fetched.get("messages", []) - bodies = [m.get("body") for m in messages] - assert body in bodies, f"sent body not visible to invitee; got {bodies}" + fetched = second_client.get_group_conversation(group["id"], limit=20) + bodies = [m.get("body") for m in fetched.get("messages", [])] + assert body in bodies, f"primary's message not visible to invitee; got {bodies}" - def test_secondary_can_send_after_accepting( + def test_send_from_secondary_visible_to_primary( self, client: ColonyClient, second_client: ColonyClient, - accepted_group: dict, + group: dict, ) -> None: - """Once accepted, the invitee can also post into the room.""" - body = f"group reply {unique_suffix()}" - sent = second_client.send_group_message(accepted_group["id"], body) + """Secondary sends → primary's ``get_group_conversation`` sees the body.""" + body = f"group probe secondary {unique_suffix()}" + sent = second_client.send_group_message(group["id"], body) assert isinstance(sent, dict) + assert sent.get("body") == body - fetched = client.get_group_conversation(accepted_group["id"], limit=20) + fetched = client.get_group_conversation(group["id"], limit=20) bodies = [m.get("body") for m in fetched.get("messages", [])] assert body in bodies, f"secondary's message not visible to creator; got {bodies}" @@ -223,14 +214,55 @@ def test_mark_group_all_read( self, client: ColonyClient, second_client: ColonyClient, - accepted_group: dict, + group: dict, ) -> None: """After a fresh message, the recipient can bulk-mark the room read.""" - client.send_group_message(accepted_group["id"], f"unread probe {unique_suffix()}") + client.send_group_message(group["id"], f"unread probe {unique_suffix()}") - result = second_client.mark_group_all_read(accepted_group["id"]) + result = second_client.mark_group_all_read(group["id"]) assert isinstance(result, dict) - # Endpoint returns ``{marked_read: int}``; accept any int including 0 - # (the message may already have been auto-marked by a prior fetch). - marked = result.get("marked_read") + # Server returns ``{marked: int}`` (the docstring's ``marked_read`` + # is wrong); accept either key just in case the field rename ships. + marked = result.get("marked", result.get("marked_read")) assert isinstance(marked, int) and marked >= 0, result + + +class TestGroupInviteAcceptPath: + """Exercise ``respond_to_group_invite(accept=True)`` when reachable. + + Empirically, the server auto-accepts invites between accounts with + a trust relationship (see module docstring). This test probes + whether the secondary's invite is actually pending and either + exercises the accept path or skips with a clear reason — so a + future run against a fresh pair of accounts (no trust history) + automatically covers the lifecycle. + """ + + def test_accept_invite_when_pending( + self, + client: ColonyClient, + second_client: ColonyClient, + me: dict, + second_me: dict, + ) -> None: + _skip_if_low_karma(me) + + title = f"sdk-it accept probe {unique_suffix()}" + g = _create_or_skip(client, title=title, members=[second_me["username"]]) + + try: + try: + response = second_client.respond_to_group_invite(g["id"], accept=True) + except ColonyAPIError as e: + if "not pending" in str(e).lower(): + pytest.skip( + "secondary's invite was auto-accepted on creation " + "(server trust-level / follow gate bypasses the pending lifecycle " + "for this account pair). Re-run against accounts with no trust " + "relationship to cover the accept path." + ) + raise + assert response.get("status") == "accepted", response + finally: + with contextlib.suppress(ColonyAPIError): + client.remove_group_member(g["id"], second_me["id"])