Files
beaver_project/external-connector/tests/test_weixin_ilink_provider.py

473 lines
16 KiB
Python

from __future__ import annotations
import json
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
from external_connector.state import SidecarStateStore
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
self.text = json.dumps(payload)
self.status_code = 200
self.is_success = True
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return self.payload
class FakeHttpClient:
def __init__(self) -> None:
self.posts: list[tuple[str, dict[str, object] | None, dict[str, str] | None]] = []
self.gets: list[str] = []
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.posts.append((url, json, headers))
if "get_bot_qrcode" in url:
return FakeResponse({"qrcode": "qr-token", "qrcode_img_content": "https://scan.example/qr"})
if "sendmessage" in url:
return FakeResponse({"ret": 0})
if "getupdates" in url:
return FakeResponse(
{
"ret": 0,
"get_updates_buf": "next-buf",
"msgs": [
{
"message_id": 42,
"from_user_id": "wx-user",
"to_user_id": "wx-bot",
"context_token": "ctx-1",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
}
],
}
)
raise AssertionError(url)
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.gets.append(url)
return FakeResponse(
{
"status": "confirmed",
"bot_token": "bot-token",
"ilink_bot_id": "bot-1@im.bot",
"baseurl": "https://api.weixin.example",
"ilink_user_id": "wx-owner",
}
)
class BusinessFailingSendHttpClient(FakeHttpClient):
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.posts.append((url, json, headers))
if "sendmessage" in url:
return FakeResponse({"ret": 47001, "errmsg": "invalid receiver"})
return super().post(url, json=json, headers=headers, timeout=timeout)
class ScannedAfterConnectedHttpClient(FakeHttpClient):
def __init__(self) -> None:
super().__init__()
self.get_count = 0
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.gets.append(url)
self.get_count += 1
if self.get_count == 1:
return FakeResponse(
{
"status": "confirmed",
"bot_token": "bot-token",
"ilink_bot_id": "bot-1@im.bot",
"baseurl": "https://api.weixin.example",
"ilink_user_id": "wx-owner",
}
)
return FakeResponse({"status": "scaned"})
def test_weixin_ilink_provider_starts_real_qr_session(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
assert session["status"] == "qr_ready"
assert session["qrCode"] == "https://scan.example/qr"
assert session["qrImage"].startswith("data:image/svg+xml;base64,")
assert http.posts[0][0].endswith("/ilink/bot/get_bot_qrcode?bot_type=3")
def test_weixin_ilink_provider_connects_on_confirmed_status(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
connected = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert connected["accountId"] == "weixin:bot-1@im.bot"
assert connected["displayName"] == "Weixin Main"
def test_weixin_ilink_provider_does_not_downgrade_token_session_to_scanned(tmp_path) -> None:
http = ScannedAfterConnectedHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
connected = provider.get_session(session["sessionId"])
refreshed = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert refreshed["status"] == "connected"
assert refreshed["accountId"] == "weixin:bot-1@im.bot"
def test_weixin_ilink_provider_recovers_token_session_persisted_as_scanned(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
provider = WeixinIlinkProvider(
store=store,
http_client=FakeHttpClient(),
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = store.create_session(
kind="weixin",
connection_id="conn_1",
channel_id="weixin-main",
display_name="Weixin Main",
options={},
)
session = store.update_session(
session.session_id,
status="scanned",
account_id="weixin:bot-1@im.bot",
metadata={
"token": "bot-token",
"baseUrl": "https://api.weixin.example",
"userId": "wx-owner",
"getUpdatesBuf": "buf",
},
)
recovered = provider.get_session(session.session_id)
assert recovered["status"] == "connected"
assert recovered["accountId"] == "weixin:bot-1@im.bot"
def test_weixin_ilink_provider_starts_existing_connected_receiver_on_startup(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
session = store.create_session(
kind="weixin",
connection_id="conn_1",
channel_id="weixin-main",
display_name="Weixin Main",
options={},
)
store.update_session(
session.session_id,
status="connected",
account_id="weixin:bot-1@im.bot",
metadata={
"token": "bot-token",
"baseUrl": "https://api.weixin.example",
"userId": "wx-owner",
"getUpdatesBuf": "buf",
},
)
provider = WeixinIlinkProvider(
store=store,
http_client=FakeHttpClient(),
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
)
assert "conn_1" in provider._receiver_stops
provider.logout("conn_1")
def test_weixin_ilink_provider_send_uses_saved_token_and_dedupes(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"contextToken": "ctx-1"},
}
first = provider.send(payload)
second = provider.send(payload)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
assert first == second
assert first["ok"] is True
assert len(send_posts) == 1
assert send_posts[0][2]["Authorization"] == "Bearer bot-token"
assert send_posts[0][1]["msg"]["from_user_id"] == ""
assert send_posts[0][1]["msg"]["to_user_id"] == "wx-user"
assert send_posts[0][1]["msg"]["client_id"] == "out_1"
assert send_posts[0][1]["msg"]["message_type"] == 2
assert send_posts[0][1]["msg"]["message_state"] == 2
assert send_posts[0][1]["msg"]["context_token"] == "ctx-1"
assert send_posts[0][1]["msg"]["item_list"][0]["text_item"]["text"] == "reply"
def test_weixin_ilink_provider_send_uses_cached_context_token(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
bridge_post=lambda url, payload, headers: None,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
result = provider.send(
{
"requestId": "out_2",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {},
}
)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
assert result["ok"] is True
assert send_posts[-1][1]["msg"]["context_token"] == "ctx-1"
def test_weixin_ilink_provider_send_uses_safe_client_id_for_platform(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.send(
{
"requestId": "out_weixin-main:account@im.bot:peer@im.wechat:msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"contextToken": "ctx-1"},
}
)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
client_id = send_posts[-1][1]["msg"]["client_id"]
assert client_id.startswith("beaver-weixin-")
assert ":" not in client_id
assert "@" not in client_id
def test_weixin_ilink_provider_send_rejects_business_error_without_completing(tmp_path) -> None:
http = BusinessFailingSendHttpClient()
store = SidecarStateStore(tmp_path / "state.json")
provider = WeixinIlinkProvider(
store=store,
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {},
}
result = provider.send(payload)
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
assert result["ok"] is False
assert "invalid receiver" in result["error"]
assert retry.should_send is True
def test_weixin_ilink_provider_poll_once_forwards_bridge_event(tmp_path) -> None:
http = FakeHttpClient()
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
bridge_posts.append((url, payload, headers))
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
bridge_post=bridge_post,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
assert bridge_posts[0][0] == "http://beaver:8080/api/channel-connector-bridge/events"
assert bridge_posts[0][2]["Authorization"] == "Bearer bridge-token"
assert bridge_posts[0][1]["eventId"] == "weixin-main:42"
assert bridge_posts[0][1]["content"] == "hello"
assert bridge_posts[0][1]["peerId"] == "wx-user"
def test_weixin_ilink_provider_poll_once_uses_session_callback_base_url(tmp_path) -> None:
http = FakeHttpClient()
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
bridge_posts.append((url, payload, headers))
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://global-beaver:8080",
bridge_token="bridge-token",
bridge_post=bridge_post,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://app-instance-jaychen:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
assert bridge_posts[0][0] == "http://app-instance-jaychen:8080/api/channel-connector-bridge/events"