"""Unit tests for the OpenWA Python SDK — assert exact paths and bodies."""

from __future__ import annotations

import httpx
import pytest

from openwa import OpenWAClient, OpenWAApiError, OpenWANotFoundError
from openwa._http import build_url
from openwa.errors import OpenWAServiceUnavailableError
from openwa.types import WebhookFilters

from conftest import MockBackend, make_client


# ── Client core ────────────────────────────────────────────────────


class TestClientCore:
    def test_requires_base_url_and_api_key(self):
        with pytest.raises(ValueError):
            OpenWAClient(base_url="", api_key="k")
        with pytest.raises(ValueError):
            OpenWAClient(base_url="http://x", api_key="")

    def test_sends_api_key_header(self):
        backend = MockBackend().on("GET", "/api/sessions", body=[])
        client = make_client(backend)
        client.sessions.list()
        assert backend.last_call.headers["x-api-key"] == "owa_k1_test"
        assert backend.last_call.headers["content-type"] == "application/json"

    def test_default_headers_cannot_override_api_key(self):
        # A caller-supplied default header must NEVER clobber the auth/JSON headers.
        backend = MockBackend().on("GET", "/api/sessions", body=[])
        client = OpenWAClient(
            base_url="http://x",
            api_key="REAL_KEY",
            default_headers={"X-API-Key": "EVIL", "Content-Type": "text/plain", "X-Trace": "keep"},
            transport=backend.as_transport(),
        )
        client.sessions.list()
        assert backend.last_call.headers["x-api-key"] == "REAL_KEY"
        assert backend.last_call.headers["content-type"] == "application/json"
        assert backend.last_call.headers["x-trace"] == "keep"  # benign custom headers still pass through

    def test_non_json_2xx_body_returns_text(self):
        import httpx

        def handler(_req: httpx.Request) -> httpx.Response:
            return httpx.Response(200, content=b"plain text", headers={"content-type": "text/plain"})

        client = OpenWAClient(base_url="http://x", api_key="k", transport=httpx.MockTransport(handler))
        # A non-JSON 2xx body must surface as text, not raise a raw JSONDecodeError.
        assert client.sessions.list() == "plain text"

    def test_path_segments_are_encoded(self):
        backend = MockBackend().on("GET", "/history", body=[])
        make_client(backend).messages.history("s", "weird/id#x")
        assert "weird%2Fid%23x" in backend.last_call.url
        backend2 = MockBackend().on("GET", "/history", body=[])
        make_client(backend2).messages.history("s", "a@c.us")
        assert "/messages/a@c.us/history" in backend2.last_call.url  # @ preserved

    def test_raw_request_escape_hatch(self):
        backend = MockBackend().on("GET", "/api/anything", body={"ok": True})
        result = make_client(backend).request("GET", "/api/anything", query={"a": 1})
        assert result == {"ok": True}
        assert "a=1" in backend.last_call.url

    def test_treats_unfollowed_redirect_as_error(self):
        import httpx

        calls: list[httpx.URL] = []

        def handler(req: httpx.Request) -> httpx.Response:
            calls.append(req.url)
            return httpx.Response(
                302,
                headers={"location": "http://evil.example/x", "content-type": "application/json"},
                content=b'{"redirected": true}',
            )

        client = OpenWAClient(base_url="http://x", api_key="k", transport=httpx.MockTransport(handler))
        # A redirect is NOT followed (which would re-send X-API-Key to the target). An unfollowed 3xx
        # is not a usable response, so it surfaces as an API error — matching the JS transport.
        with pytest.raises(OpenWAApiError):
            client.sessions.list()
        assert len(calls) == 1  # the redirect target was never requested

    def test_strips_trailing_slash(self):
        backend = MockBackend().on("GET", "/api/sessions", body=[])
        client = OpenWAClient(base_url="http://localhost:2785/", api_key="k", transport=backend.as_transport())
        client.sessions.list()
        assert backend.last_call.url == "http://localhost:2785/api/sessions"

    def test_query_params_skip_none(self):
        backend = MockBackend().on("GET", "/messages", body=[])
        make_client(backend).messages.list("s1", {"chatId": "a@c.us", "limit": 10})
        url = backend.last_call.url
        assert "chatId=a%40c.us" in url
        assert "limit=10" in url

    def test_build_url_omits_none_valued_params(self):
        # The behaviour a caller depends on: a None never reaches the wire as the literal "None".
        # Tested here because build_url is what implements it — the typed resource signatures
        # forbid None, so proving it through one of them would mean lying about a type.
        url = build_url("http://x", "/api/search", {"q": "x", "sessionId": None, "limit": 5})

        assert "q=x" in url
        assert "limit=5" in url
        assert "sessionId" not in url
        assert "None" not in url

    def test_204_is_none(self):
        # `delete` is declared `-> None`, so asserting on its result is a type error and proves
        # nothing the signature does not already guarantee. What is worth asserting is that a 204
        # completes without trying to parse an empty body as JSON.
        backend = MockBackend().on("DELETE", "/api/sessions", status=204)
        make_client(backend).sessions.delete("x")
        assert backend.last_call.method == "DELETE"

    def test_404_maps_to_not_found_error(self):
        backend = MockBackend().on("GET", "/api/sessions/missing", status=404, body={
            "statusCode": 404, "message": "Session not found", "error": "Not Found"
        })
        with pytest.raises(OpenWANotFoundError):
            make_client(backend).sessions.get("missing")

    def test_maps_503_to_service_unavailable(self):
        # The gateway answers 503 when the engine never confirmed an operation -- the one typed error
        # here worth retrying. It used to fall through to the base class while the permanent 501 had a
        # subclass of its own.
        backend = MockBackend()
        backend.on("GET", "/api/sessions/s1", 503, {
            "statusCode": 503, "message": "WhatsApp did not answer", "error": "Service Unavailable"
        })
        with pytest.raises(OpenWAServiceUnavailableError):
            make_client(backend).sessions.get("s1")

    def test_exposes_all_resources(self):
        client = make_client(MockBackend())
        for r in ["sessions", "messages", "contacts", "groups", "webhooks", "chats", "status", "health", "search", "profile", "calls"]:
            assert hasattr(client, r)


# ── Messages (the critical send-text fix) ──────────────────────────


class TestMessages:
    def test_send_text_uses_send_text_path(self):
        backend = MockBackend().on("POST", "/send-text", body={"messageId": "m1", "timestamp": 1})
        make_client(backend).messages.send_text("s1", {"chatId": "a@c.us", "text": "hi"})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s1/messages/send-text"
        assert backend.last_call.body == {"chatId": "a@c.us", "text": "hi"}

    def test_send_text_forwards_mentions_verbatim(self):
        backend = MockBackend().on("POST", "/send-text", body={"messageId": "m1", "timestamp": 1})
        make_client(backend).messages.send_text("s1", {"chatId": "g@g.us", "text": "hi @628123", "mentions": ["628123@c.us"]})
        assert backend.last_call.body == {"chatId": "g@g.us", "text": "hi @628123", "mentions": ["628123@c.us"]}

    def test_send_poll_uses_send_poll_path(self):
        backend = MockBackend().on("POST", "/send-poll", body={"messageId": "m2", "timestamp": 2})
        res = make_client(backend).messages.send_poll("s1", {
            "chatId": "a@c.us", "name": "Where?", "options": ["Park", "Beach"], "allowMultipleAnswers": True,
        })
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s1/messages/send-poll"
        assert backend.last_call.body == {
            "chatId": "a@c.us", "name": "Where?", "options": ["Park", "Beach"], "allowMultipleAnswers": True,
        }
        assert res["messageId"] == "m2"

    @pytest.mark.parametrize("method,segment", [
        ("send_image", "send-image"),
        ("send_video", "send-video"),
        ("send_audio", "send-audio"),
        ("send_document", "send-document"),
        ("send_sticker", "send-sticker"),
    ])
    def test_media_segments(self, method, segment):
        backend = MockBackend().on("POST", f"/{segment}", body={"messageId": "m", "timestamp": 2})
        fn = getattr(make_client(backend).messages, method)
        fn("s", {"chatId": "a@c.us", "url": "u"})
        assert f"/messages/{segment}" in backend.last_call.url

    def test_send_location_contact_template(self):
        backend = MockBackend()
        backend.on("POST", "/send-location", body={"messageId": "m", "timestamp": 1})
        backend.on("POST", "/send-contact", body={"messageId": "m", "timestamp": 1})
        backend.on("POST", "/send-template", body={"messageId": "m", "timestamp": 1})
        client = make_client(backend)
        client.messages.send_location("s", {"chatId": "a@c.us", "latitude": -6.2, "longitude": 106.8})
        assert "/messages/send-location" in backend.last_call.url
        client.messages.send_contact("s", {"chatId": "a@c.us", "contactName": "A", "contactNumber": "628"})
        assert "/messages/send-contact" in backend.last_call.url
        # Server DTO field is `vars` (NOT `variables`); body forwarded verbatim.
        client.messages.send_template("s", {"chatId": "a@c.us", "templateId": "t", "vars": {"name": "Sam"}})
        assert "/messages/send-template" in backend.last_call.url
        assert backend.last_call.body == {"chatId": "a@c.us", "templateId": "t", "vars": {"name": "Sam"}}

    def test_send_template_accepts_template_name(self):
        backend = MockBackend().on("POST", "/send-template", body={"messageId": "m", "timestamp": 1})
        make_client(backend).messages.send_template("s", {"chatId": "a@c.us", "templateName": "welcome"})
        assert backend.last_call.body == {"chatId": "a@c.us", "templateName": "welcome"}

    def test_list_returns_messages_total_wrapper(self):
        backend = MockBackend().on("GET", "/messages", body={"messages": [{"id": "1"}], "total": 1})
        res = make_client(backend).messages.list("s")
        assert res["total"] == 1
        assert len(res["messages"]) == 1

    def test_quoted_message_id_reaches_the_body_on_every_send(self):
        # Asserted on the BODY, not the URL: a URL assertion cannot see a dropped key, and dropping
        # one is exactly how a client ends up 400ing while its four siblings work.
        backend = MockBackend()
        for route in ("send-text", "send-image", "send-location", "send-contact", "send-poll"):
            backend.on("POST", f"/{route}", body={"messageId": "m", "timestamp": 1})
        client = make_client(backend)

        client.messages.send_text("s", {"chatId": "a@c.us", "text": "hi", "quotedMessageId": "q1"})
        client.messages.send_image("s", {"chatId": "a@c.us", "url": "http://u", "quotedMessageId": "q1"})
        client.messages.send_location(
            "s", {"chatId": "a@c.us", "latitude": 1, "longitude": 2, "quotedMessageId": "q1"}
        )
        client.messages.send_contact(
            "s", {"chatId": "a@c.us", "contactName": "A", "contactNumber": "628", "quotedMessageId": "q1"}
        )
        client.messages.send_poll(
            "s", {"chatId": "a@c.us", "name": "Q", "options": ["a", "b"], "quotedMessageId": "q1"}
        )

        for call in backend.calls[-5:]:
            assert call.body["quotedMessageId"] == "q1", call.url

    def test_ordinary_send_carries_no_quote_key(self):
        # Known-negative control: without it an implementation that always emitted the key — as null,
        # which some clients drop and others send — would satisfy the assertion above.
        backend = MockBackend().on("POST", "/send-image", body={"messageId": "m", "timestamp": 1})
        make_client(backend).messages.send_image("s", {"chatId": "a@c.us", "url": "http://u"})
        assert "quotedMessageId" not in backend.calls[-1].body

    def test_reply_forwardReactDelete(self):
        backend = MockBackend()
        backend.on("POST", "/reply", body={"messageId": "m", "timestamp": 1})
        backend.on("POST", "/forward", body={"messageId": "m", "timestamp": 1})
        backend.on("POST", "/react", body={"success": True})
        backend.on("POST", "/delete", body={"success": True})
        client = make_client(backend)
        client.messages.reply("s", {"chatId": "a@c.us", "quotedMessageId": "q", "text": "r"})
        client.messages.forward("s", {"fromChatId": "a@c.us", "toChatId": "b@c.us", "messageId": "m"})
        client.messages.react("s", {"chatId": "a@c.us", "messageId": "m", "emoji": "👍"})
        client.messages.delete("s", {"chatId": "a@c.us", "messageId": "m"})
        assert "/messages/reply" in backend.calls[-4].url
        assert "/messages/forward" in backend.calls[-3].url
        assert "/messages/react" in backend.calls[-2].url
        assert "/messages/delete" in backend.calls[-1].url

    def test_edit_message(self):
        backend = MockBackend().on("POST", "/messages/edit", body={"messageId": "m1", "timestamp": 3})
        res = make_client(backend).messages.edit_message("s1", {"chatId": "a@c.us", "messageId": "m1", "body": "edited"})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s1/messages/edit"
        assert backend.last_call.body == {"chatId": "a@c.us", "messageId": "m1", "body": "edited"}
        assert res["messageId"] == "m1"

    def test_history_and_reactions_path(self):
        backend = MockBackend()
        backend.on("GET", "/history", body=[])
        backend.on("GET", "/reactions", body=[])
        client = make_client(backend)
        client.messages.history("s", "a@c.us", {"limit": 5})
        assert "/messages/a@c.us/history" in backend.calls[-1].url
        assert "limit=5" in backend.calls[-1].url
        client.messages.reactions("s", "a@c.us", "m1")
        assert "/messages/a@c.us/m1/reactions" in backend.calls[-1].url

    def test_history_boolean_query_serializes_lowercase(self):
        # Server reads `includeMedia === 'true'`; Python str(True) == 'True' would be ignored.
        backend = MockBackend().on("GET", "/history", body=[])
        make_client(backend).messages.history("s", "a@c.us", {"limit": 5, "includeMedia": True, "deep": False})
        url = backend.last_call.url
        assert "includeMedia=true" in url
        assert "deep=false" in url

    def test_bulk_and_batch_status(self):
        backend = MockBackend()
        backend.on("POST", "/send-bulk", body={
            "batchId": "b", "status": "queued", "totalMessages": 1,
            "estimatedCompletionTime": "t", "statusUrl": "/u",
        })
        backend.on("GET", "/batch/b", body={
            "batchId": "b", "status": "done",
            "progress": {"total": 1, "sent": 1, "failed": 0, "pending": 0, "cancelled": 0},
            "results": [], "startedAt": "s", "completedAt": "c",
        })
        backend.on("POST", "/cancel", body={
            "batchId": "b", "status": "cancelled",
            "progress": {"total": 1, "sent": 0, "failed": 0, "pending": 0, "cancelled": 1},
        })
        client = make_client(backend)
        client.messages.send_bulk("s", {"messages": [{"chatId": "a@c.us", "type": "text", "content": {"text": "x"}}]})
        assert "/messages/send-bulk" in backend.calls[-1].url
        status = client.messages.batch_status("s", "b")
        assert status["progress"]["sent"] == 1
        assert "/messages/batch/b" in backend.calls[-1].url
        cancelled = client.messages.cancel_batch("s", "b")
        assert cancelled["status"] == "cancelled"
        assert "/messages/batch/b/cancel" in backend.calls[-1].url
        assert backend.calls[-1].method == "POST"


# ── Sessions ───────────────────────────────────────────────────────


class TestSessions:
    def test_lifecycle_paths(self):
        backend = MockBackend()
        backend.on("GET", "/api/sessions", body=[])
        backend.on("GET", "/sessions/s1", body={"id": "s1", "name": "n", "status": "ready"})
        backend.on("POST", "/api/sessions", body={"id": "s1", "name": "n", "status": "created"})
        backend.on("DELETE", "/sessions/s1", status=204)
        backend.on("POST", "/start", body={"id": "s1", "status": "initializing"})
        backend.on("POST", "/stop", body={"id": "s1", "status": "disconnected"})
        backend.on("POST", "/logout", body={"id": "s1", "status": "disconnected"})
        backend.on("POST", "/force-kill", body={"id": "s1", "status": "disconnected"})
        client = make_client(backend)
        client.sessions.list()
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions"
        client.sessions.get("s1")
        assert "/sessions/s1" in backend.calls[-1].url
        client.sessions.create({"name": "n"})
        assert backend.calls[-1].body == {"name": "n"}
        client.sessions.start("s1")
        assert "/sessions/s1/start" in backend.calls[-1].url
        client.sessions.stop("s1")
        client.sessions.logout("s1")
        assert "/sessions/s1/logout" in backend.calls[-1].url
        client.sessions.force_kill("s1")
        assert "/sessions/s1/force-kill" in backend.calls[-1].url
        client.sessions.delete("s1")
        assert backend.calls[-1].method == "DELETE"

    def test_qr_pairing_stats(self):
        backend = MockBackend()
        backend.on("GET", "/qr", body={"qrCode": "data:image/png;base64,xxx", "status": "qr_ready"})
        backend.on("POST", "/pairing-code", body={"pairingCode": "ABCD1234", "status": "qr_ready"})
        backend.on("GET", "/stats/overview", body={"total": 1, "active": 1, "ready": 1, "disconnected": 0, "byStatus": {"ready": 1}})
        client = make_client(backend)
        client.sessions.get_qr_code("s1")
        assert "/sessions/s1/qr" in backend.calls[-1].url
        client.sessions.request_pairing_code("s1", {"phoneNumber": "628123456789"})
        assert backend.calls[-1].body == {"phoneNumber": "628123456789"}
        client.sessions.stats()
        assert "/sessions/stats/overview" in backend.calls[-1].url

    def test_set_online_presence(self):
        backend = MockBackend()
        backend.on("PUT", "/presence", body={"success": True})
        client = make_client(backend)
        client.sessions.set_online_presence("s1", {"available": False})
        # The account's own presence: no chat id in the path and no /subscribe suffix.
        assert backend.calls[-1].method == "PUT"
        assert backend.calls[-1].url.endswith("/sessions/s1/presence")
        assert backend.calls[-1].body == {"available": False}


# ── Groups, Contacts, Webhooks, Chats, Health ──────────────────────


class TestGroups:
    def test_list_get_create(self):
        backend = MockBackend()
        backend.on("GET", "/groups", body=[])
        backend.on("GET", "/groups/g1@g.us", body={"id": "g1@g.us", "subject": "G", "participants": []})
        backend.on("POST", "/groups", body={"id": "g1@g.us", "subject": "G", "participants": []})
        client = make_client(backend)
        client.groups.list("s")
        client.groups.get("s", "g1@g.us")
        assert "/groups/g1@g.us" in backend.calls[-1].url
        client.groups.create("s", {"name": "G", "participants": ["a@c.us"]})
        assert backend.calls[-1].body == {"name": "G", "participants": ["a@c.us"]}

    def test_participant_ops(self):
        backend = MockBackend()
        backend.on("POST", "/participants", body={"success": True})
        backend.on("DELETE", "/participants", body={"success": True})
        backend.on("POST", "/promote", body={"success": True})
        backend.on("POST", "/demote", body={"success": True})
        client = make_client(backend)
        client.groups.add_participants("s", "g", ["a@c.us", "b@c.us"])
        assert backend.calls[-1].body == {"participants": ["a@c.us", "b@c.us"]}
        assert backend.calls[-1].method == "POST"
        client.groups.remove_participants("s", "g", ["a@c.us"])
        assert backend.calls[-1].method == "DELETE"
        client.groups.promote_participants("s", "g", ["a@c.us"])
        assert "/promote" in backend.calls[-1].url
        client.groups.demote_participants("s", "g", ["a@c.us"])
        assert "/demote" in backend.calls[-1].url

    def test_subject_description_invite(self):
        backend = MockBackend()
        backend.on("PUT", "/subject", body={"success": True})
        backend.on("PUT", "/description", body={"success": True})
        backend.on("POST", "/leave", body={"success": True})
        backend.on("GET", "/invite-code", body={"inviteCode": "c", "inviteLink": "l"})
        backend.on("POST", "/revoke", body={"inviteCode": "c2", "inviteLink": "l2"})
        client = make_client(backend)
        client.groups.set_subject("s", "g", "New")
        assert backend.calls[-1].body == {"subject": "New"}
        assert backend.calls[-1].method == "PUT"
        client.groups.set_description("s", "g", "desc")
        assert backend.calls[-1].body == {"description": "desc"}
        client.groups.leave("s", "g")
        client.groups.invite_code("s", "g")
        client.groups.revoke_invite_code("s", "g")
        assert "/revoke" in backend.calls[-1].url


    def test_join_group(self):
        backend = MockBackend().on("POST", "/groups/join", body={"success": True, "groupId": "g1@g.us"})
        res = make_client(backend).groups.join_group("s", {"inviteCode": "ABCxyz"})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/groups/join"
        assert backend.last_call.body == {"inviteCode": "ABCxyz"}
        assert res["groupId"] == "g1@g.us"

    def test_group_settings_get_and_update(self):
        backend = MockBackend()
        backend.on("GET", "/settings", body={"announce": True, "locked": False, "ephemeralSeconds": 604800})
        backend.on("PUT", "/settings", body={"success": True, "message": "Group settings updated"})
        client = make_client(backend)
        settings = client.groups.get_group_settings("s", "g1@g.us")
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/groups/g1@g.us/settings"
        assert settings["announce"] is True
        assert settings["ephemeralSeconds"] == 604800
        res = client.groups.update_group_settings("s", "g1@g.us", {"announce": False, "ephemeralSeconds": 0})
        assert backend.calls[-1].method == "PUT"
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/groups/g1@g.us/settings"
        assert backend.calls[-1].body == {"announce": False, "ephemeralSeconds": 0}
        assert res["success"] is True


class TestProfile:
    def test_set_profile_name_and_status(self):
        backend = MockBackend()
        backend.on("PUT", "/profile/name", body={"success": True, "message": "Profile name updated"})
        backend.on("PUT", "/profile/status", body={"success": True, "message": "Profile status updated"})
        client = make_client(backend)
        client.profile.set_profile_name("s", {"name": "My Business"})
        assert backend.calls[-1].method == "PUT"
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/profile/name"
        assert backend.calls[-1].body == {"name": "My Business"}
        # An empty status clears the about text; the body is forwarded verbatim.
        client.profile.set_profile_status("s", {"status": ""})
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/profile/status"
        assert backend.calls[-1].body == {"status": ""}

    def test_set_profile_picture_url_and_base64(self):
        backend = MockBackend().on("PUT", "/profile/picture", body={"success": True, "message": "Profile picture updated"})
        client = make_client(backend)
        client.profile.set_profile_picture("s", {"url": "https://example.com/avatar.jpg"})
        assert backend.calls[-1].method == "PUT"
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/profile/picture"
        assert backend.calls[-1].body == {"url": "https://example.com/avatar.jpg"}
        client.profile.set_profile_picture("s", {"base64": "aGVsbG8=", "mimetype": "image/jpeg"})
        assert backend.calls[-1].body == {"base64": "aGVsbG8=", "mimetype": "image/jpeg"}

    def test_delete_profile_picture(self):
        backend = MockBackend().on("DELETE", "/profile/picture", body={"success": True, "message": "Profile picture removed"})
        client = make_client(backend)
        client.profile.delete_profile_picture("s")
        assert backend.calls[-1].method == "DELETE"
        assert backend.calls[-1].url == "http://localhost:2785/api/sessions/s/profile/picture"
        assert backend.calls[-1].body is None


class TestMedia:
    def test_conversion_status(self):
        backend = MockBackend().on("GET", "/media/convert", body={"available": True})
        res = make_client(backend).media.conversion_status("s")
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/media/convert"
        assert res["available"] is True

    def test_convert_voice(self):
        backend = MockBackend().on(
            "POST", "/media/convert/voice", body={"base64": "T2dnUw==", "mimetype": "audio/ogg; codecs=opus", "bytes": 8}
        )
        res = make_client(backend).media.convert_voice("s", base64="SUQz")
        assert backend.last_call.method == "POST"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/media/convert/voice"
        # Only the field that was given: a blank one reads as supplied-but-empty.
        assert backend.last_call.body == {"base64": "SUQz"}
        assert res["mimetype"] == "audio/ogg; codecs=opus"

    def test_convert_video_with_url(self):
        backend = MockBackend().on(
            "POST", "/media/convert/video", body={"base64": "AAAA", "mimetype": "video/mp4", "bytes": 4}
        )
        make_client(backend).media.convert_video("s", url="https://example.com/c.mov")
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/media/convert/video"
        assert backend.last_call.body == {"url": "https://example.com/c.mov"}


class TestCalls:
    def test_reject_call(self):
        backend = MockBackend().on("POST", "/reject", body={"success": True})
        res = make_client(backend).calls.reject_call("s", "CALL1")
        assert backend.last_call.method == "POST"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/calls/CALL1/reject"
        assert backend.last_call.body is None  # no request body
        assert res["success"] is True

    def test_reject_call_404_maps_to_not_found(self):
        backend = MockBackend().on("POST", "/reject", status=404, body={
            "statusCode": 404, "message": "Call not found or no longer ringing", "error": "Not Found"
        })
        with pytest.raises(OpenWANotFoundError):
            make_client(backend).calls.reject_call("s", "CALL1")


class TestContacts:
    def test_paths(self):
        backend = MockBackend()
        backend.on("GET", "/contacts", body=[])
        backend.on("GET", "/contacts/a@c.us", body={"id": "a@c.us"})
        backend.on("GET", "/check/628123", body={"number": "628123", "exists": True, "whatsappId": "628123@c.us"})
        backend.on("GET", "/profile-picture", body={"url": "http://p"})
        backend.on("GET", "/phone", body={"contactId": "x@lid", "phone": "628123"})
        client = make_client(backend)
        client.contacts.list("s", {"limit": 10})
        assert "limit=10" in backend.calls[-1].url
        client.contacts.get("s", "a@c.us")
        client.contacts.check("s", "628123")
        assert "/check/628123" in backend.calls[-1].url
        client.contacts.profile_picture("s", "a@c.us")
        client.contacts.phone("s", "x@lid")

    def test_block_unblock(self):
        backend = MockBackend()
        backend.on("POST", "/block", body={"success": True})
        backend.on("DELETE", "/block", body={"success": True})
        client = make_client(backend)
        client.contacts.block("s", "a@c.us")
        assert backend.calls[-1].method == "POST"
        client.contacts.unblock("s", "a@c.us")
        assert backend.calls[-1].method == "DELETE"

    def test_list_blocked_gets_session_wide_route(self):
        backend = MockBackend().on("GET", "/contacts/blocked", body=["a@c.us", "b@c.us"])
        client = make_client(backend)
        res = client.contacts.list_blocked("s")
        call = backend.calls[-1]
        assert call.method == "GET"
        # Session-wide: no contact id in the path, and not the /contacts list route.
        assert call.url.endswith("/api/sessions/s/contacts/blocked")
        assert call.body is None
        assert res == ["a@c.us", "b@c.us"]

    def test_profile_pictures_batch_resolves_ids_query(self):
        backend = MockBackend().on("GET", "/contacts/profile-pictures", body={
            "pictures": {"a@c.us": "http://p/a", "b@c.us": None}
        })
        res = make_client(backend).contacts.profile_pictures("s", ["a@c.us", "b@c.us"])
        assert backend.last_call.method == "GET"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/contacts/profile-pictures?ids=a%40c.us%2Cb%40c.us"
        assert res["pictures"] == {"a@c.us": "http://p/a", "b@c.us": None}


class TestWebhooks:
    def test_crud_test(self):
        wh = {"id": "w1", "sessionId": "s", "url": "u", "events": ["*"], "active": True, "createdAt": "", "updatedAt": ""}
        backend = MockBackend()
        backend.on("GET", "/webhooks", body=[wh])
        backend.on("GET", "/webhooks/w1", body=wh)
        backend.on("POST", "/webhooks", body=wh)
        backend.on("PUT", "/webhooks/w1", body={**wh, "active": False})
        backend.on("DELETE", "/webhooks/w1", status=204)
        backend.on("POST", "/test", body={"success": True})
        client = make_client(backend)
        client.webhooks.list("s")
        client.webhooks.get("s", "w1")
        # Server DTO field is `retryCount` (NOT `retries`); body forwarded verbatim.
        client.webhooks.create("s", {"url": "u", "events": ["*"], "retryCount": 5})
        assert backend.calls[-1].body == {"url": "u", "events": ["*"], "retryCount": 5}
        client.webhooks.update("s", "w1", {"active": False})
        assert backend.calls[-1].method == "PUT"
        client.webhooks.delete("s", "w1")
        client.webhooks.test("s", "w1")
        assert "/webhooks/w1/test" in backend.calls[-1].url

    def test_create_forwards_polymorphic_filter_values_verbatim(self):
        backend = MockBackend().on("POST", "/webhooks", body={"id": "w1"})
        # Annotated, not inferred: an unannotated literal widens to dict[str, list[object]] and the
        # call below then proves nothing about the declared filter shape — which is the whole point
        # of a test named "forwards polymorphic filter values verbatim".
        filters: WebhookFilters = {
            "conditions": [
                {"field": "sender", "operator": "is", "value": ["123@c.us"]},
                {"field": "body", "operator": "contains", "value": "invoice", "caseSensitive": True},
                {"field": "isGroup", "operator": "is", "value": False},
            ]
        }
        make_client(backend).webhooks.create("s", {"url": "u", "events": ["message.received"], "filters": filters})
        assert backend.last_call.body == {"url": "u", "events": ["message.received"], "filters": filters}


class TestStatus:
    def test_send_image_video_forward_nested_media_body(self):
        backend = MockBackend()
        backend.on("POST", "/status/send-image", body={"statusId": "s1"})
        backend.on("POST", "/status/send-video", body={"statusId": "s2"})
        backend.on("POST", "/status/send-voice", body={"statusId": "s3"})
        client = make_client(backend)
        # Server requires a nested {image|video:{...}} body, not flat media fields,
        # plus a required recipients list.
        client.status.send_image("s", {"image": {"url": "http://img"}, "recipients": ["a@c.us"], "caption": "hi"})
        assert backend.calls[-1].body == {"image": {"url": "http://img"}, "recipients": ["a@c.us"], "caption": "hi"}
        client.status.send_video("s", {"video": {"url": "http://vid"}, "recipients": ["a@c.us"]})
        assert backend.calls[-1].body == {"video": {"url": "http://vid"}, "recipients": ["a@c.us"]}
        # A voice status takes an `audio` wrapper and carries no caption — WhatsApp has nowhere to
        # render one on a status voice note.
        client.status.send_voice("s", {"audio": {"base64": "T2dnUw=="}, "recipients": ["a@c.us"]})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/status/send-voice"
        assert backend.calls[-1].body == {"audio": {"base64": "T2dnUw=="}, "recipients": ["a@c.us"]}

    def test_vote_poll_posts_option_texts(self):
        backend = MockBackend().on("POST", "/messages/vote-poll", body={"success": True})
        make_client(backend).messages.vote_poll("s", {"chatId": "c1", "pollMessageId": "p1", "options": ["Pizza"]})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/messages/vote-poll"
        assert backend.last_call.body == {"chatId": "c1", "pollMessageId": "p1", "options": ["Pizza"]}

    def test_star_posts_the_boolean_through(self):
        backend = MockBackend().on("POST", "/messages/star", body={"success": True})
        make_client(backend).messages.star("s", {"chatId": "c1", "messageId": "m1", "star": False})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/messages/star"
        assert backend.last_call.body == {"chatId": "c1", "messageId": "m1", "star": False}

    def test_pin_and_unpin_post_to_their_routes(self):
        backend = MockBackend().on("POST", "/messages/pin", body={"success": True})
        backend.on("POST", "/messages/unpin", body={"success": True})
        client = make_client(backend)
        client.messages.pin("s", {"chatId": "c1", "messageId": "m1", "durationSeconds": 604800})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/messages/pin"
        assert backend.last_call.method == "POST"
        client.messages.unpin("s", {"chatId": "c1", "messageId": "m1"})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/messages/unpin"

    def test_message_media_fetches_archived_bytes(self):
        backend = MockBackend()
        backend.fallback = lambda _: httpx.Response(200, content=b"PNG_BYTES", headers={"content-type": "image/png"})
        res = make_client(backend).messages.media("s", "c1", "m1")
        assert backend.last_call.method == "GET"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/messages/c1/m1/media"
        assert res == {"data": b"PNG_BYTES", "contentType": "image/png"}

    def test_media_fetches_stored_status_bytes(self):
        backend = MockBackend()
        backend.fallback = lambda _: httpx.Response(200, content=b"PNG_BYTES", headers={"content-type": "image/png"})
        res = make_client(backend).status.media("s", "w1")
        assert backend.last_call.method == "GET"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/status/w1/media"
        assert res == {"data": b"PNG_BYTES", "contentType": "image/png"}

    def test_media_404_maps_to_not_found_error(self):
        backend = MockBackend()
        backend.fallback = lambda _: httpx.Response(
            404, content=b'{"statusCode": 404, "message": "Status media not found or expired"}'
        )
        with pytest.raises(OpenWANotFoundError):
            make_client(backend).status.media("s", "w1")


class TestChatsAndHealth:
    def test_chats(self):
        backend = MockBackend()
        backend.on("GET", "/chats", body=[])
        backend.on("POST", "/read", body={"success": True})
        backend.on("POST", "/unread", body={"success": True})
        backend.on("POST", "/delete", body={"success": True})
        backend.on("POST", "/typing", body={"success": True})
        client = make_client(backend)
        client.chats.list("s")
        client.chats.mark_read("s", {"chatId": "a@c.us"})
        assert "/chats/read" in backend.calls[-1].url
        client.chats.mark_unread("s", {"chatId": "a@c.us"})
        client.chats.delete("s", {"chatId": "a@c.us"})
        client.chats.send_state("s", {"chatId": "a@c.us", "state": "typing"})
        assert "/chats/typing" in backend.calls[-1].url

    def test_chats_clear_messages(self):
        backend = MockBackend().on("DELETE", "/messages", body={"success": True})
        make_client(backend).chats.clear_messages("s", "a@c.us")
        assert backend.last_call.method == "DELETE"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/chats/a@c.us/messages"

    def test_groups_picture(self):
        backend = MockBackend().on("GET", "/picture", body={"url": "https://x/p.jpg"})
        backend.on("PUT", "/picture", body={"success": True})
        backend.on("DELETE", "/picture", body={"success": True})
        client = make_client(backend)
        assert client.groups.get_picture("s", "g@g.us") == {"url": "https://x/p.jpg"}
        client.groups.set_picture("s", "g@g.us", {"url": "https://x/new.jpg"})
        assert backend.last_call.method == "PUT"
        client.groups.delete_picture("s", "g@g.us")
        assert backend.last_call.method == "DELETE"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/groups/g@g.us/picture"

    def test_contacts_addressbook(self):
        backend = MockBackend().on("PUT", "/contacts/", body={"success": True})
        backend.on("DELETE", "/contacts/", body={"success": True})
        client = make_client(backend)
        client.contacts.upsert("s", "a@c.us", {"firstName": "Ada"})
        assert backend.last_call.method == "PUT"
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/contacts/a@c.us"
        client.contacts.delete("s", "a@c.us")
        assert backend.last_call.method == "DELETE"

    def test_chats_archive(self):
        backend = MockBackend().on("POST", "/chats/archive", body={"success": True})
        make_client(backend).chats.archive("s", {"chatId": "a@c.us", "archive": True})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/chats/archive"
        assert backend.last_call.body == {"chatId": "a@c.us", "archive": True}

    def test_chats_pin(self):
        backend = MockBackend().on("POST", "/chats/pin", body={"success": True})
        make_client(backend).chats.pin("s", {"chatId": "a@c.us", "pin": True})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/chats/pin"
        assert backend.last_call.body == {"chatId": "a@c.us", "pin": True}

    def test_chats_pin_reports_refusal(self):
        backend = MockBackend().on("POST", "/chats/pin", body={"success": False})
        assert make_client(backend).chats.pin("s", {"chatId": "a@c.us", "pin": True}) == {"success": False}

    def test_chats_mute_sends_epoch_milliseconds_unchanged(self):
        # The value must arrive as the exact millisecond number given. A client that divided by 1000
        # would still get a 200 back; the wrong unit is only visible on the wire.
        backend = MockBackend().on("POST", "/chats/mute", body={"success": True})
        make_client(backend).chats.mute("s", {"chatId": "a@c.us", "muteUntil": 1893456000000})
        assert backend.last_call.url == "http://localhost:2785/api/sessions/s/chats/mute"
        assert backend.last_call.body == {"chatId": "a@c.us", "muteUntil": 1893456000000}

    def test_chats_mute_sends_explicit_null_to_unmute(self):
        # None is the unmute signal and is NOT the same as omitting the key, which the route rejects.
        backend = MockBackend().on("POST", "/chats/mute", body={"success": True})
        make_client(backend).chats.mute("s", {"chatId": "a@c.us", "muteUntil": None})
        assert backend.last_call.body == {"chatId": "a@c.us", "muteUntil": None}
        assert "muteUntil" in backend.last_call.body

    def test_health_and_auth(self):
        backend = MockBackend()
        backend.on("GET", "/api/health", body={"status": "ok", "version": "0.7.2"})
        backend.on("GET", "/live", body={"status": "ok"})
        backend.on("GET", "/ready", body={"status": "ok", "details": {}})
        backend.on("POST", "/validate", body={"valid": True, "role": "admin"})
        client = make_client(backend)
        client.health.check()
        assert backend.calls[-1].url == "http://localhost:2785/api/health"
        client.health.live()
        client.health.ready()
        client.auth()
        assert backend.calls[-1].method == "POST"
        assert "/auth/validate" in backend.calls[-1].url


class TestLabelsChannelsCatalog:
    def test_labels(self):
        backend = MockBackend()
        backend.on("GET", "/labels", body=[{"id": "l1", "name": "VIP"}])
        backend.on("GET", "/labels/l1", body={"id": "l1", "name": "VIP"})
        backend.on("GET", "/labels/chat/a@c.us", body=[{"id": "l1", "name": "VIP"}])
        backend.on("POST", "/labels/chat/a@c.us", body={"success": True})
        backend.on("DELETE", "/labels/chat/a@c.us/l1", body={"success": True})
        client = make_client(backend)
        client.labels.list("s")
        assert "/sessions/s/labels" in backend.calls[-1].url
        client.labels.get("s", "l1")
        client.labels.for_chat("s", "a@c.us")
        client.labels.add_to_chat("s", "a@c.us", {"labelId": "l1"})
        assert backend.calls[-1].method == "POST"
        assert backend.calls[-1].body == {"labelId": "l1"}
        client.labels.remove_from_chat("s", "a@c.us", "l1")
        assert backend.calls[-1].method == "DELETE"

    def test_channels(self):
        backend = MockBackend()
        backend.on("GET", "/channels", body=[{"id": "123@newsletter", "name": "News"}])
        backend.on("GET", "/channels/123@newsletter", body={"id": "123@newsletter", "name": "News"})
        backend.on("GET", "/channels/123@newsletter/messages", body=[])
        backend.on("POST", "/channels/subscribe", body={"id": "123@newsletter", "name": "News"})
        backend.on("DELETE", "/channels/123@newsletter", body={"success": True})
        client = make_client(backend)
        client.channels.list("s")
        assert "/sessions/s/channels" in backend.calls[-1].url
        client.channels.get("s", "123@newsletter")
        client.channels.messages("s", "123@newsletter", {"limit": 10})
        assert "limit=10" in backend.calls[-1].url
        client.channels.subscribe("s", {"inviteCode": "ABCxyz"})
        assert backend.calls[-1].method == "POST"
        assert backend.calls[-1].body == {"inviteCode": "ABCxyz"}
        client.channels.unsubscribe("s", "123@newsletter")
        assert backend.calls[-1].method == "DELETE"

    def test_catalog(self):
        backend = MockBackend()
        backend.on("GET", "/catalog", body={"id": "c1", "name": "My Shop", "productCount": 5, "url": "http://shop"})
        backend.on("GET", "/catalog/products", body={
            "products": [{"id": "p1", "name": "Widget"}],
            "pagination": {"page": 1, "limit": 20, "total": 1, "totalPages": 1},
        })
        backend.on("GET", "/catalog/products/p1", body={"id": "p1", "name": "Widget"})
        backend.on("POST", "/messages/send-product", body={"messageId": "m", "timestamp": 1})
        client = make_client(backend)
        client.catalog.info("s")
        assert "/sessions/s/catalog" in backend.calls[-1].url
        page = client.catalog.products("s", {"page": 1, "limit": 20})
        assert page["pagination"]["total"] == 1
        assert "page=1" in backend.calls[-1].url
        client.catalog.product("s", "p1")
        client.catalog.send_product("s", {"chatId": "a@c.us", "productId": "p1", "body": "x"})
        assert "/messages/send-product" in backend.calls[-1].url
        assert backend.calls[-1].body == {"chatId": "a@c.us", "productId": "p1", "body": "x"}

    def test_templates_crud(self):
        tpl = {"id": "t1", "sessionId": "s", "name": "welcome", "body": "Hi {{name}}", "createdAt": "", "updatedAt": ""}
        backend = MockBackend()
        backend.on("GET", "/templates/t1", body=tpl)
        backend.on("GET", "/templates", body=[tpl])
        backend.on("POST", "/templates", body=tpl)
        backend.on("PUT", "/templates/t1", body={**tpl, "body": "Hello {{name}}"})
        backend.on("DELETE", "/templates/t1", status=204)
        client = make_client(backend)
        client.templates.list("s")
        assert "/api/sessions/s/templates" in backend.last_call.url
        client.templates.get("s", "t1")
        assert backend.last_call.url.endswith("/api/sessions/s/templates/t1")
        client.templates.create("s", {"name": "welcome", "body": "Hi {{name}}"})
        assert backend.last_call.method == "POST"
        assert backend.last_call.body == {"name": "welcome", "body": "Hi {{name}}"}
        client.templates.update("s", "t1", {"body": "Hello {{name}}"})
        assert backend.last_call.method == "PUT"
        assert backend.last_call.body == {"body": "Hello {{name}}"}
        client.templates.delete("s", "t1")
        assert backend.last_call.method == "DELETE"

    def test_client_exposes_all_resources(self):
        client = make_client(MockBackend())
        for r in ["sessions", "messages", "contacts", "groups", "webhooks", "chats", "status", "health", "labels", "channels", "catalog", "templates", "search", "profile", "calls"]:
            assert hasattr(client, r)


# ── Search ─────────────────────────────────────────────────────────


class TestSearch:
    def test_search_minimal_required_q(self):
        backend = MockBackend().on("GET", "/api/search", body={
            "hits": [], "total": 0, "tookMs": 3, "provider": "builtin-fts",
        })
        res = make_client(backend).search.search({"q": "hello"})
        assert res["total"] == 0
        assert res["provider"] == "builtin-fts"
        assert backend.last_call.url == "http://localhost:2785/api/search?q=hello"

    def test_search_forwards_all_optional_filters(self):
        backend = MockBackend().on("GET", "/api/search", body={
            "hits": [], "total": 0, "tookMs": 1, "provider": "builtin-fts",
        })
        make_client(backend).search.search({
            "q": "invoice",
            "sessionId": "s1",
            "chatId": "628123@c.us",
            "direction": "incoming",
            "type": "chat",
            "from": "628123456789@c.us",
            "dateFrom": 1720000000000,
            "dateTo": 1720100000000,
            "limit": 20,
            "offset": 40,
        })
        url = backend.last_call.url
        assert url.startswith("http://localhost:2785/api/search?")
        assert "q=invoice" in url
        assert "sessionId=s1" in url
        assert "chatId=628123%40c.us" in url  # @ percent-encoded in query
        assert "direction=incoming" in url
        assert "type=chat" in url
        assert "from=628123456789%40c.us" in url
        assert "dateFrom=1720000000000" in url
        assert "dateTo=1720100000000" in url
        assert "limit=20" in url
        assert "offset=40" in url

    def test_search_returns_hits_shape(self):
        hit = {
            "messageId": "m1", "waMessageId": "wam1", "sessionId": "s1",
            "chatId": "628123@c.us", "body": "please send the invoice",
            "snippet": "please send the <mark>invoice</mark>", "timestamp": 1720000000,
            "type": "chat", "direction": "incoming", "from": "628123456789@c.us",
            "score": 0.42,
        }
        backend = MockBackend().on("GET", "/api/search", body={
            "hits": [hit], "total": 1, "tookMs": 7, "provider": "builtin-fts",
        })
        res = make_client(backend).search.search({"q": "invoice"})
        assert res["total"] == 1
        assert res["hits"][0]["messageId"] == "m1"
        assert res["hits"][0]["snippet"] == "please send the <mark>invoice</mark>"
        assert res["hits"][0]["score"] == 0.42
        assert res["hits"][0]["direction"] == "incoming"

    def test_search_none_values_skipped_in_query(self):
        # Asserted against build_url, which is where the omission actually happens, rather than by
        # routing `sessionId: None` through the typed resource. The contract has sessionId optional
        # but NOT nullable (openapi.json: required false, type string, no nullable), so the
        # TypedDict is right to demand a str and the old call was sending a payload the gateway
        # would reject. Testing the mechanism at its owner keeps the regression covered without
        # anything having to lie about the type.
        backend = MockBackend().on("GET", "/api/search", body={
            "hits": [], "total": 0, "tookMs": 0, "provider": "builtin-fts",
        })
        make_client(backend).search.search({"q": "x", "limit": 5})
        url = backend.last_call.url
        assert "q=x" in url
        assert "limit=5" in url
        assert "sessionId" not in url


class TestGroupMembershipRequests:
    def test_list_approve_reject(self):
        backend = MockBackend()
        backend.on("GET", "/membership-requests", body=[{"participantId": "a@c.us", "method": "invite_link"}])
        backend.on("POST", "/membership-requests/approve", body={"success": True, "message": "ok", "results": []})
        backend.on("POST", "/membership-requests/reject", body={"success": True, "message": "ok", "results": []})
        client = make_client(backend)

        pending = client.groups.get_membership_requests("s", "g1@g.us")
        assert backend.calls[-1].method == "GET"
        assert backend.calls[-1].url.endswith("/groups/g1@g.us/membership-requests")
        assert pending[0]["participantId"] == "a@c.us"

        client.groups.approve_membership_requests("s", "g1@g.us", ["a@c.us"])
        assert backend.calls[-1].url.endswith("/membership-requests/approve")
        assert backend.calls[-1].body == {"participants": ["a@c.us"]}

        # Omitting the list means "every pending request": an empty body, not a null participants key.
        client.groups.reject_membership_requests("s", "g1@g.us")
        assert backend.calls[-1].url.endswith("/membership-requests/reject")
        assert backend.calls[-1].body == {}


class TestCallLink:
    def test_create_link(self):
        backend = MockBackend()
        backend.on("POST", "/calls/link", body={"link": "https://call.whatsapp.com/video/AbC"})
        client = make_client(backend)
        res = client.calls.create_link("s", {"type": "video", "startTime": 1800000000000})
        assert backend.calls[-1].method == "POST"
        assert backend.calls[-1].url.endswith("/sessions/s/calls/link")
        assert backend.calls[-1].body == {"type": "video", "startTime": 1800000000000}
        assert "call.whatsapp.com" in res["link"]
class TestChannelAdminOwnership:
    def test_demote_and_transfer(self):
        backend = MockBackend()
        backend.on("POST", "/admins/demote", body={"success": True})
        backend.on("POST", "/owner/transfer", body={"success": True})
        client = make_client(backend)

        client.channels.demote_admin("s", "c@newsletter", {"userId": "a@c.us"})
        assert backend.calls[-1].url.endswith("/channels/c@newsletter/admins/demote")
        assert backend.calls[-1].body == {"userId": "a@c.us"}

        client.channels.transfer_ownership("s", "c@newsletter", {"newOwnerId": "b@c.us"})
        assert backend.calls[-1].url.endswith("/channels/c@newsletter/owner/transfer")
        assert backend.calls[-1].body == {"newOwnerId": "b@c.us"}
