Files
gaokao-volunteer-system/data/channel_sync/tests/test_wechat_adapter.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

275 lines
9.2 KiB
Python

"""T8.2 微信 SDK 集成测试
覆盖:
- access token 获取/缓存/过期刷新
- 订阅消息 payload 规范化与发送
- 客服文本消息 payload 与可选客服账号字段
- 上游 errcode / HTTP 异常透传为结构化错误
- 环境变量缺失 fail-fast
"""
from __future__ import annotations
import json
import os
import unittest
from collections.abc import Callable
from dataclasses import dataclass
from typing import Optional
from data.channel_sync.wechat_adapter import (
WeChatAPIError,
WeChatClient,
WeChatConfig,
WeChatConfigError,
)
@dataclass
class _FakeResponse:
status: int
payload: dict
class _FakeTransport:
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = list(responses)
self.calls: list[dict] = []
def request(
self,
method: str,
url: str,
*,
headers: Optional[dict[str, str]] = None,
body: Optional[bytes] = None,
timeout: float = 0,
) -> tuple[int, bytes]:
self.calls.append(
{
"method": method,
"url": url,
"headers": headers or {},
"body": body,
"timeout": timeout,
}
)
if not self._responses:
raise AssertionError("no fake response left")
response = self._responses.pop(0)
return response.status, json.dumps(response.payload).encode("utf-8")
class _ExplodingTransport:
def request(
self,
method: str,
url: str,
*,
headers: Optional[dict[str, str]] = None,
body: Optional[bytes] = None,
timeout: float = 0,
) -> tuple[int, bytes]:
raise TimeoutError("network down")
class WeChatClientTests(unittest.TestCase):
def make_client(
self,
responses: list[_FakeResponse],
*,
clock: Callable[[], float] | None = None,
) -> tuple[WeChatClient, _FakeTransport]:
transport = _FakeTransport(responses)
client = WeChatClient(
config=WeChatConfig(
app_id="wx-test-appid",
app_secret="wx-test-secret",
api_base="https://api.weixin.qq.com",
timeout_seconds=3.0,
token_refresh_skew_seconds=60,
),
transport=transport,
clock=clock,
)
return client, transport
def test_access_token_cached_until_expiry(self) -> None:
now = [1000.0]
client, transport = self.make_client(
[
_FakeResponse(
200,
{
"access_token": "token-1",
"expires_in": 7200,
},
),
_FakeResponse(
200,
{
"errcode": 0,
"errmsg": "ok",
"msgid": 123,
},
),
_FakeResponse(
200,
{
"errcode": 0,
"errmsg": "ok",
"msgid": 124,
},
),
],
clock=lambda: now[0],
)
client.send_subscribe_message(
openid="openid-1",
template_id="tmpl-1",
data={"thing1": "待付款提醒"},
)
now[0] += 300
client.send_custom_text(openid="openid-1", content="您好,这里是客服")
self.assertEqual(len(transport.calls), 3)
self.assertIn("/cgi-bin/token?", transport.calls[0]["url"])
self.assertIn(
"access_token=token-1",
transport.calls[1]["url"] + transport.calls[2]["url"],
)
token_fetches = [
call for call in transport.calls if "/cgi-bin/token?" in call["url"]
]
self.assertEqual(len(token_fetches), 1)
def test_access_token_refreshes_after_expiry(self) -> None:
now = [1000.0]
client, transport = self.make_client(
[
_FakeResponse(200, {"access_token": "token-1", "expires_in": 120}),
_FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 1}),
_FakeResponse(200, {"access_token": "token-2", "expires_in": 120}),
_FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 2}),
],
clock=lambda: now[0],
)
client.send_custom_text(openid="openid-1", content="first")
now[0] += 70
client.send_custom_text(openid="openid-1", content="second")
token_fetches = [
call for call in transport.calls if "/cgi-bin/token?" in call["url"]
]
self.assertEqual(len(token_fetches), 2)
self.assertIn("access_token=token-2", transport.calls[-1]["url"])
def test_send_subscribe_message_normalizes_payload(self) -> None:
client, transport = self.make_client(
[
_FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}),
_FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 9001}),
]
)
result = client.send_subscribe_message(
openid="openid-1",
template_id="tmpl-1",
data={
"thing1": "支付成功",
"date2": {"value": "2026-06-12 18:00"},
},
page="pages/orders/detail?id=GKO-1",
miniprogram_state="formal",
lang="zh_CN",
)
self.assertEqual(result["msgid"], 9001)
send_call = transport.calls[-1]
payload = json.loads(send_call["body"].decode("utf-8"))
self.assertEqual(payload["touser"], "openid-1")
self.assertEqual(payload["template_id"], "tmpl-1")
self.assertEqual(payload["data"]["thing1"], {"value": "支付成功"})
self.assertEqual(payload["data"]["date2"], {"value": "2026-06-12 18:00"})
self.assertEqual(payload["page"], "pages/orders/detail?id=GKO-1")
self.assertEqual(payload["miniprogram_state"], "formal")
self.assertEqual(payload["lang"], "zh_CN")
def test_send_custom_text_with_kf_account(self) -> None:
client, transport = self.make_client(
[
_FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}),
_FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 7001}),
]
)
result = client.send_custom_text(
openid="openid-2",
content="请补充考生分数和位次",
kf_account="advisor@test",
)
self.assertEqual(result["msgid"], 7001)
payload = json.loads(transport.calls[-1]["body"].decode("utf-8"))
self.assertEqual(payload["msgtype"], "text")
self.assertEqual(payload["text"]["content"], "请补充考生分数和位次")
self.assertEqual(payload["customservice"], {"kf_account": "advisor@test"})
def test_upstream_errcode_raises_structured_error(self) -> None:
client, _ = self.make_client(
[
_FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}),
_FakeResponse(200, {"errcode": 40003, "errmsg": "invalid openid"}),
]
)
with self.assertRaises(WeChatAPIError) as cm:
client.send_custom_text(openid="bad-openid", content="hello")
self.assertEqual(cm.exception.errcode, 40003)
self.assertIn("invalid openid", str(cm.exception))
def test_http_error_raises_structured_error(self) -> None:
client, _ = self.make_client(
[
_FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}),
_FakeResponse(500, {"errcode": 0, "errmsg": "server busy"}),
]
)
with self.assertRaises(WeChatAPIError) as cm:
client.send_custom_text(openid="openid-1", content="hello")
self.assertEqual(cm.exception.http_status, 500)
def test_transport_error_wrapped_as_wechat_api_error(self) -> None:
client = WeChatClient(
config=WeChatConfig(
app_id="wx-test-appid",
app_secret="wx-test-secret",
),
transport=_ExplodingTransport(),
)
with self.assertRaises(WeChatAPIError) as cm:
client.send_custom_text(openid="openid-1", content="hello")
self.assertIn("transport_error", str(cm.exception))
def test_config_from_env_missing_secret_fails_fast(self) -> None:
old_id = os.environ.get("GAOKAO_WECHAT_APP_ID")
old_secret = os.environ.get("GAOKAO_WECHAT_APP_SECRET")
try:
os.environ["GAOKAO_WECHAT_APP_ID"] = "wx-appid"
os.environ.pop("GAOKAO_WECHAT_APP_SECRET", None)
with self.assertRaises(WeChatConfigError):
WeChatClient.from_env()
finally:
if old_id is None:
os.environ.pop("GAOKAO_WECHAT_APP_ID", None)
else:
os.environ["GAOKAO_WECHAT_APP_ID"] = old_id
if old_secret is None:
os.environ.pop("GAOKAO_WECHAT_APP_SECRET", None)
else:
os.environ["GAOKAO_WECHAT_APP_SECRET"] = old_secret