# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Invited is not joined: the join attempt and its memory of failures.""" from __future__ import annotations from typing import TYPE_CHECKING from urllib.parse import quote import httpx import pytest from signal_bot.membership import ( JoinAttempts, attempt_join, entry_matches, find_entry, ) from tests.conftest import SECOND_GROUP, WATCHED_GROUP, WATCHED_INTERNAL, group_entry if TYPE_CHECKING: from signal_bot.config import Config def _client(handler: object) -> httpx.AsyncClient: return httpx.AsyncClient(transport=httpx.MockTransport(handler)) def test_entry_matches_either_spelling() -> None: """Envelopes carry internal_id; the listing reports the prefixed form.""" assert entry_matches({"internal_id": WATCHED_INTERNAL}, WATCHED_GROUP) assert entry_matches({"id": WATCHED_GROUP}, WATCHED_GROUP) assert not entry_matches({"id": SECOND_GROUP}, WATCHED_GROUP) def test_find_entry_returns_none_when_absent() -> None: assert find_entry([group_entry(SECOND_GROUP)], WATCHED_GROUP) is None assert find_entry([group_entry(WATCHED_GROUP)], WATCHED_GROUP) is not None def test_join_attempts_remembers_and_forgets() -> None: """Without this, an impossible invite is retried every 30s forever.""" attempts = JoinAttempts() assert not attempts.has_failed(WATCHED_GROUP) assert attempts.reason(WATCHED_GROUP) == "not attempted" attempts.record_failure(WATCHED_GROUP, "no service ID") assert attempts.has_failed(WATCHED_GROUP) assert attempts.reason(WATCHED_GROUP) == "no service ID" attempts.forget(WATCHED_GROUP) assert not attempts.has_failed(WATCHED_GROUP) async def test_attempt_join_percent_encodes_the_group_id(config: Config) -> None: """The internal spelling carries '/' and '+', which are not path-safe.""" group_id = "Y29uZmlnLWdyb3VwLWlkLTMy" seen: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request.url.raw_path.decode()) return httpx.Response(201, text="") detail = await attempt_join(_client(handler), config, group_id) assert seen == [f"/v1/groups/{config.phone_number}/{quote(group_id, safe='')}/join"] assert "201" in detail async def test_attempt_join_reports_a_refusal_instead_of_raising(config: Config) -> None: """The caller's membership re-check is the authority, not this response.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(400, text="Cannot find service ID for self") detail = await attempt_join(_client(handler), config, WATCHED_GROUP) assert "400" in detail assert "Cannot find service ID" in detail async def test_attempt_join_survives_an_endpoint_that_is_not_there(config: Config) -> None: def handler(request: httpx.Request) -> httpx.Response: msg = "connection refused" raise httpx.ConnectError(msg) assert "join request failed" in await attempt_join(_client(handler), config, WATCHED_GROUP) @pytest.mark.parametrize("body", ["", "x" * 400]) async def test_attempt_join_truncates_a_long_body(config: Config, body: str) -> None: """A whole HTML error page in a log line helps nobody.""" detail = await attempt_join( _client(lambda r: httpx.Response(500, text=body)), config, WATCHED_GROUP ) assert len(detail) < 260