CI / backend (push) Canceled after 0s
生产把应用挂在域名根、路由带 /api/v1 前缀,request.base_url 里没有前缀, 拼出来的地址少了这一段;本地被 Vite 的代理掩盖了。改成从 base_url 截站点根、 前缀一律取 settings.api_prefix,并补一条断言把这个差异钉住。
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""微信(clawbot)接入:一条消息进来,变成任务或知识。
|
|
|
|
测的是**整条链路**:令牌 → 租户上下文 → 建事项 → 留痕 → 重复推送幂等。
|
|
webhook 不带登录态,所以这里故意不用 `auth()` 头,只有管理接口才带。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from httpx import AsyncClient
|
|
|
|
from .conftest import create_workspace, login, unwrap
|
|
|
|
|
|
async def _channel(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict:
|
|
return unwrap(
|
|
await client.post(
|
|
f"/api/v1/workspaces/{ws['id']}/integrations",
|
|
json={"name": "我的微信"},
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
|
|
|
|
async def _post(client: AsyncClient, token: str, payload: dict[str, Any]):
|
|
return await client.post(f"/api/v1/integrations/clawbot/{token}", json=payload)
|
|
|
|
|
|
async def test_plain_message_becomes_task(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信接入")
|
|
channel = await _channel(client, user, ws)
|
|
|
|
resp = await _post(
|
|
client,
|
|
channel["token"],
|
|
{"msg_id": "wx-1", "from": "steven", "text": "给客户回电话\n顺便确认下报价"},
|
|
)
|
|
data = unwrap(resp)
|
|
assert data["action"] == "task"
|
|
assert data["duplicate"] is False
|
|
assert "给客户回电话" in data["reply"]
|
|
|
|
items = unwrap(
|
|
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
|
)["items"]
|
|
task = next(i for i in items if i["title"] == "给客户回电话")
|
|
assert task["kind"] == "task"
|
|
assert task["status"] == "inbox"
|
|
assert "顺便确认下报价" in task["body_md"]
|
|
|
|
|
|
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信知识")
|
|
channel = await _channel(client, user, ws)
|
|
|
|
data = unwrap(
|
|
await _post(
|
|
client,
|
|
channel["token"],
|
|
{"text": "知识:回滚前先停灰度 #发布 #回滚\n1. 停流量\n2. 回滚镜像"},
|
|
)
|
|
)
|
|
assert data["action"] == "knowledge"
|
|
|
|
docs = unwrap(
|
|
await client.get(
|
|
f"/api/v1/workspaces/{ws['id']}/knowledge?status=draft", headers=user["headers"]
|
|
)
|
|
)["items"]
|
|
doc = next(d for d in docs if d["title"] == "回滚前先停灰度")
|
|
assert doc["knowledge"]["status_hint"] == "draft"
|
|
assert doc["knowledge"]["doc_type"] == "note"
|
|
assert {t["name"] for t in doc["tags"]} == {"发布", "回滚"}
|
|
# 标签从标题摘出去以后,正文里不该再留 # 号
|
|
assert "#发布" not in doc["body_md"]
|
|
|
|
|
|
async def test_help_reply_creates_nothing(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信帮助")
|
|
channel = await _channel(client, user, ws)
|
|
|
|
data = unwrap(await _post(client, channel["token"], {"text": "帮助"}))
|
|
assert data["action"] == "help"
|
|
assert "记成任务" in data["reply"]
|
|
items = unwrap(
|
|
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
|
)["items"]
|
|
assert items == []
|
|
|
|
|
|
async def test_unknown_token_is_404(client: AsyncClient) -> None:
|
|
resp = await client.post("/api/v1/integrations/clawbot/nope", json={"text": "x"})
|
|
assert resp.status_code == 404
|
|
assert resp.json()["code"] == 9002
|
|
|
|
|
|
async def test_disabled_channel_refuses_messages(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信停用")
|
|
channel = await _channel(client, user, ws)
|
|
await client.patch(
|
|
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}",
|
|
json={"status": "disabled"},
|
|
headers=user["headers"],
|
|
)
|
|
resp = await _post(client, channel["token"], {"text": "这条不该进去"})
|
|
assert resp.status_code == 403
|
|
assert resp.json()["code"] == 9003
|
|
|
|
|
|
async def test_same_message_id_is_idempotent(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信重推")
|
|
channel = await _channel(client, user, ws)
|
|
payload = {"msg_id": "dup-1", "text": "只该有一条"}
|
|
|
|
first = unwrap(await _post(client, channel["token"], payload))
|
|
second = unwrap(await _post(client, channel["token"], payload))
|
|
assert second["duplicate"] is True
|
|
assert second["reply"] == first["reply"]
|
|
|
|
items = unwrap(
|
|
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
|
)["items"]
|
|
assert [i["title"] for i in items] == ["只该有一条"]
|
|
|
|
|
|
async def test_get_verify_echoes_challenge(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信校验")
|
|
channel = await _channel(client, user, ws)
|
|
|
|
resp = await client.get(f"/api/v1/integrations/clawbot/{channel['token']}?echostr=abc123")
|
|
assert resp.status_code == 200
|
|
assert resp.text == "abc123"
|
|
|
|
|
|
async def test_token_belongs_to_one_workspace(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws_a = await create_workspace(client, user, "A 区")
|
|
ws_b = await create_workspace(client, user, "B 区")
|
|
channel = await _channel(client, user, ws_a)
|
|
|
|
await _post(client, channel["token"], {"text": "只该进 A 区"})
|
|
|
|
items_a = unwrap(
|
|
await client.get(f"/api/v1/workspaces/{ws_a['id']}/items", headers=user["headers"])
|
|
)["items"]
|
|
items_b = unwrap(
|
|
await client.get(f"/api/v1/workspaces/{ws_b['id']}/items", headers=user["headers"])
|
|
)["items"]
|
|
assert [i["title"] for i in items_a] == ["只该进 A 区"]
|
|
assert items_b == []
|
|
|
|
|
|
async def test_rotate_invalidates_old_token(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信换令牌")
|
|
channel = await _channel(client, user, ws)
|
|
rotated = unwrap(
|
|
await client.post(
|
|
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}/rotate",
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
assert rotated["token"] != channel["token"]
|
|
old = await _post(client, channel["token"], {"text": "旧令牌"})
|
|
assert old.status_code == 404
|
|
new = unwrap(await _post(client, rotated["token"], {"text": "新令牌能用"}))
|
|
assert new["action"] == "task"
|
|
|
|
|
|
async def test_member_cannot_manage_channels(client: AsyncClient) -> None:
|
|
owner = await login(client)
|
|
ws = await create_workspace(client, owner, "微信权限")
|
|
member = await login(client)
|
|
code = unwrap(
|
|
await client.post(
|
|
f"/api/v1/workspaces/{ws['id']}/invitations",
|
|
json={"role": "member"},
|
|
headers=owner["headers"],
|
|
)
|
|
)
|
|
await client.post(
|
|
f"/api/v1/workspaces/{ws['id']}/join",
|
|
json={"code": code["code"]},
|
|
headers=member["headers"],
|
|
)
|
|
resp = await client.post(
|
|
f"/api/v1/workspaces/{ws['id']}/integrations",
|
|
json={"name": "偷加的通道"},
|
|
headers=member["headers"],
|
|
)
|
|
assert resp.status_code == 403
|
|
assert resp.json()["code"] in (3004, 3003)
|
|
|
|
|
|
async def test_webhook_url_carries_api_prefix(client: AsyncClient) -> None:
|
|
"""回调地址必须带 /api/v1:生产上应用挂在域名根,漏了前缀就是 405。"""
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "微信地址")
|
|
channel = await _channel(client, user, ws)
|
|
assert channel["webhook_url"].endswith(f"/api/v1/integrations/clawbot/{channel['token']}")
|