Files
DadaoTask/backend/tests/test_wechat_bot.py
T

338 lines
12 KiB
Python

"""微信 clawbot:扫码登录 + 长轮询 + 消息落成任务/知识。
不联网:iLink 的四个接口都用 monkeypatch 顶掉,验的是我们这侧的流程与落库:
取二维码 → 等扫码 → 存加密登录态 → 轮询拿到消息 → 建事项 → 留痕 → 回复 → 去重。
"""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select, text
from app.db import SessionFactory, bind_tenant
from app.models_integrations import BotChannel, BotMessage
from app.models_task import Item
from app.services import wechat_bot, wechat_ilink
from app.services.wechat_intake import handle_text
from .conftest import create_workspace, login, unwrap
@pytest.fixture(autouse=True)
def no_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
"""别在测试里真起轮询任务:这里只验"该不该起",任务体另测。"""
async def fake_start(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
return None
async def fake_stop(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
return None
monkeypatch.setattr(wechat_bot.BotManager, "start", fake_start)
monkeypatch.setattr(wechat_bot.BotManager, "stop", fake_stop)
async def _begin(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict[str, Any]:
return unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr",
headers=user["headers"],
)
)
def _patch_qr(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_qr() -> wechat_ilink.QrCode:
return wechat_ilink.QrCode(qrcode_id="qr-1", payload="https://wx.example/qr/1")
monkeypatch.setattr(wechat_ilink, "get_qrcode", fake_qr)
def _patch_status(monkeypatch: pytest.MonkeyPatch, status: wechat_ilink.QrStatus) -> None:
async def fake_status(qrcode_id: str) -> wechat_ilink.QrStatus: # noqa: ARG001
return status
monkeypatch.setattr(wechat_ilink, "qrcode_status", fake_status)
async def test_begin_login_returns_scannable_qr(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
data = await _begin(client, user, ws)
assert data["qrcode_id"] == "qr-1"
assert data["payload"] == "https://wx.example/qr/1"
assert data["image"].startswith("data:image/svg+xml;base64,")
channels = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
)
assert channels["items"][0]["status"] == "pending"
assert channels["items"][0]["connected"] is False
async def test_scan_confirms_and_stores_encrypted_token(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(
monkeypatch,
wechat_ilink.QrStatus(
status="confirmed", bot_token="bot-token-abc", ilink_user_id="wx-user-9"
),
)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
started = await _begin(client, user, ws)
result = unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
assert result["status"] == "confirmed"
assert result["channel"]["connected"] is True
assert result["channel"]["ilink_user_id"] == "wx-user-9"
channel_id = uuid.UUID(started["channel_id"])
async with SessionFactory() as session:
await session.execute(
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
)
channel = await session.get(BotChannel, channel_id)
assert channel is not None
assert channel.bot_token_enc is not None
# 落库的是密文,且能解回原值
assert "bot-token-abc" not in channel.bot_token_enc
assert wechat_ilink.decrypt_token(channel.bot_token_enc) == "bot-token-abc"
async def test_stale_qr_poll_is_reported_as_expired(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
await _begin(client, user, ws)
data = unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/another-qr",
headers=user["headers"],
)
)
assert data["status"] == "expired"
async def test_disconnect_clears_login_state(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
data = unwrap(
await client.delete(
f"/api/v1/workspaces/{ws['id']}/integrations/{started['channel_id']}",
headers=user["headers"],
)
)
assert data["channel"]["connected"] is False
assert data["channel"]["status"] == "pending"
async def test_plain_text_becomes_task(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(
session, channel, "给客户回电话\n顺便确认报价", base_url="http://site"
)
await session.commit()
assert result["action"] == "task"
assert "已记下任务" in result["reply"]
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
assert item is not None
assert item.title == "给客户回电话"
assert item.kind == "task"
assert item.status == "inbox"
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(
session,
channel,
"知识:回滚前先停灰度 #发布 #回滚\n先停流量,再退镜像版本",
base_url="http://site",
)
await session.commit()
assert result["action"] == "knowledge"
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
assert item is not None
assert item.kind == "knowledge"
assert item.status == "draft"
assert item.title == "回滚前先停灰度"
assert "#" not in item.title
async def test_help_does_not_create_anything(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(session, channel, "帮助", base_url="http://site")
await session.commit()
assert result["action"] == "help"
assert result["item_id"] is None
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
items = (
(await session.execute(select(Item).where(Item.workspace_id == ws_id))).scalars().all()
)
assert items == []
async def test_poller_handles_message_once_and_replies(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""同一个外部消息 id 到两次,只能有一条任务、一条留痕、一次回复。"""
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "轮询")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
channel_id = uuid.UUID(started["channel_id"])
replies: list[tuple[str, str]] = []
async def fake_send(
token: str, to: str, context: str | None, text: str, *, base_url: str | None = None
) -> None: # noqa: ARG001
replies.append((to, text))
monkeypatch.setattr(wechat_ilink, "send_text", fake_send)
message = wechat_ilink.Inbound(
message_id="msg-1",
sender="wx-user-9",
text="整理一下本周排期",
context_token="ctx-1",
)
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
assert len(replies) == 1
assert replies[0][0] == "wx-user-9"
assert "已记下任务" in replies[0][1]
async with SessionFactory() as session:
await session.execute(
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
)
channel = await session.get(BotChannel, channel_id)
assert channel is not None
assert channel.context_token == "ctx-1"
assert channel.last_seen_at is not None
# bot_message 走标准租户隔离,验它得先绑定工作区
await bind_tenant(session, uuid.UUID(ws["id"]), uuid.UUID(user["user_id"]))
messages = (
(await session.execute(select(BotMessage).where(BotMessage.channel_id == channel_id)))
.scalars()
.all()
)
assert len(messages) == 1
assert messages[0].action == "task"
async def test_stale_token_marks_channel_expired(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "轮询")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
channel_id = uuid.UUID(started["channel_id"])
await wechat_bot.manager._mark_relogin(channel_id, "登录态已失效,需要重新扫码")
channels = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
)
assert channels["items"][0]["status"] == "expired"
assert channels["items"][0]["connected"] is False
assert "重新扫码" in channels["items"][0]["last_error"]
async def test_upstream_wait_status_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
"""上游说 "wait",页面只认 waiting——这一层要对齐,否则界面停在"等扫码结果…"。"""
async def fake_post(path: str, body: dict, **kwargs: Any) -> dict: # noqa: ARG001
return {"status": "wait"}
monkeypatch.setattr(wechat_ilink, "_post", fake_post)
result = await wechat_ilink.qrcode_status("qr-x")
assert result.status == "waiting"