Files
MoviePilot/tests/test_feishu_openapi.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

306 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
飞书 OpenAPI 客户端测试:令牌缓存与失效重试、请求路径与请求体、上传下载和失败归一。
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
from app.modules.feishu import openapi
from app.modules.feishu.openapi import NETWORK_ERROR_CODE, FeishuOpenApi, _parse_file_name
class FakeResponse:
"""
模拟 requests.Response 中客户端用到的属性。
"""
def __init__(self, body: Any = None, status_code: int = 200, headers: Optional[Dict[str, str]] = None,
content: bytes = b""):
self._body = body
self.status_code = status_code
self.reason = "OK" if status_code == 200 else "Bad Request"
self.headers = headers or {}
self.content = content
def json(self):
"""
返回预设响应正文;正文缺失时模拟 JSON 解析失败。
"""
if self._body is None:
raise ValueError("not json")
return self._body
class FakeHttp:
"""
替代 RequestUtils:记录每次请求,按队列返回预设响应。
"""
def __init__(self, token_responses: List[FakeResponse], responses: List[Optional[FakeResponse]]):
self.token_responses = list(token_responses)
self.responses = list(responses)
self.token_calls: List[Dict[str, Any]] = []
self.calls: List[Dict[str, Any]] = []
def post_res(self, url, **kwargs):
"""
记录令牌请求参数,并按顺序返回预设令牌响应。
"""
self.token_calls.append({"url": url, **kwargs})
return self.token_responses.pop(0)
def request(self, method, url, **kwargs):
"""
记录业务请求参数,并按顺序返回预设响应。
"""
self.calls.append({"method": method, "url": url, **kwargs})
return self.responses.pop(0)
def close(self):
"""
模拟关闭 HTTP 客户端,不持有需要释放的外部资源。
"""
pass
def _token(value: str = "t-1", expire: int = 7200) -> FakeResponse:
return FakeResponse({"code": 0, "msg": "ok", "tenant_access_token": value, "expire": expire})
def _client(http: FakeHttp) -> FeishuOpenApi:
client = FeishuOpenApi("cli_app", "secret")
client._http = http
return client
def _ok(data: Optional[dict] = None, log_id: str = "log-1") -> FakeResponse:
return FakeResponse({"code": 0, "msg": "success", "data": data or {}}, headers={"X-Tt-Logid": log_id})
def test_create_message_request_and_cached_token():
"""
发送消息使用 IM 接口的路径、查询参数与请求体,令牌只获取一次并复用。
"""
http = FakeHttp([_token()], [_ok({"message_id": "om_1"}), _ok({"message_id": "om_2"})])
client = _client(http)
first = client.create_message("open_id", "ou_1", "text", '{"text": "hi"}', "uuid-1")
client.create_message("chat_id", "oc_1", "text", '{"text": "again"}', "uuid-2")
assert first.success()
assert first.data == {"message_id": "om_1"}
assert first.log_id == "log-1"
assert http.token_calls == [{
"url": "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
"json": {"app_id": "cli_app", "app_secret": "secret"},
}]
call = http.calls[0]
assert (call["method"], call["url"]) == ("POST", "https://open.feishu.cn/open-apis/im/v1/messages")
assert call["params"] == {"receive_id_type": "open_id"}
assert call["json"] == {"receive_id": "ou_1", "msg_type": "text", "content": '{"text": "hi"}', "uuid": "uuid-1"}
assert call["headers"]["Authorization"] == "Bearer t-1"
assert http.calls[1]["headers"]["Authorization"] == "Bearer t-1"
def test_token_refreshes_ten_minutes_before_expiry(monkeypatch):
"""
令牌在过期前 10 分钟即重新获取。
"""
now = [1000.0]
monkeypatch.setattr(openapi.time, "time", lambda: now[0])
http = FakeHttp([_token("t-1", expire=7200), _token("t-2")], [_ok(), _ok()])
client = _client(http)
client.patch_message("om_1", "{}")
now[0] += 7200 - 10 * 60 + 1
client.patch_message("om_1", "{}")
assert [c["headers"]["Authorization"] for c in http.calls] == ["Bearer t-1", "Bearer t-2"]
def test_invalid_token_is_refreshed_and_retried_once():
"""
接口判定令牌失效时刷新令牌并重试一次,不会无限重试。
"""
invalid = FakeResponse({"code": 99991663, "msg": "Invalid access token"}, status_code=400)
http = FakeHttp([_token("stale"), _token("fresh")], [invalid, _ok({"reaction_id": "r1"})])
client = _client(http)
response = client.create_message_reaction("om_1", "GLANCE")
assert response.data == {"reaction_id": "r1"}
assert [c["headers"]["Authorization"] for c in http.calls] == ["Bearer stale", "Bearer fresh"]
assert http.calls[1]["json"] == {"reaction_type": {"emoji_type": "GLANCE"}}
def test_token_failure_skips_request():
"""
获取令牌失败时不发出业务请求,并返回本地错误码。
"""
http = FakeHttp([FakeResponse({"code": 10003, "msg": "invalid app_secret"})], [])
client = _client(http)
response = client.create_card("{}")
assert not response.success()
assert response.code == NETWORK_ERROR_CODE
assert http.calls == []
def test_business_error_keeps_code_and_log_id():
"""
开放平台业务错误原样保留 code/msg/log_id,便于日志排查。
"""
error = FakeResponse({"code": 230002, "msg": "bot not in chat"}, status_code=400, headers={"X-Tt-Logid": "L9"})
http = FakeHttp([_token()], [error])
response = _client(http).reply_message("om_1", "text", "{}", False, "u")
assert (response.code, response.msg, response.log_id, response.data) == (230002, "bot not in chat", "L9", {})
@pytest.mark.parametrize("response", [None, FakeResponse(None, status_code=502)])
def test_network_failure_and_non_json_are_normalized(response):
"""
网络失败或非 JSON 响应统一为失败响应,不抛异常。
"""
http = FakeHttp([_token()], [response])
result = _client(http).patch_message("om_1", "{}")
assert not result.success()
assert result.code == NETWORK_ERROR_CODE
def test_path_params_are_url_encoded():
"""
路径参数整体编码,含 / 或 ? 的 ID 不会改写请求目标。
"""
http = FakeHttp([_token()], [_ok()])
_client(http).delete_message_reaction("om/1?x", "r#1")
assert http.calls[0]["method"] == "DELETE"
assert http.calls[0]["url"] == "https://open.feishu.cn/open-apis/im/v1/messages/om%2F1%3Fx/reactions/r%231"
def test_cardkit_requests():
"""
CardKit 创建、流式更新与设置更新使用各自的方法、路径和请求体。
"""
http = FakeHttp([_token()], [_ok({"card_id": "c1"}), _ok(), _ok()])
client = _client(http)
assert client.create_card('{"schema": "2.0"}').data == {"card_id": "c1"}
client.update_card_element_content("c1", "body", "文本", 2, "u2")
client.update_card_settings("c1", '{"config": {}}', 3, "u3")
create, content, settings = http.calls
assert (create["method"], create["url"]) == ("POST", "https://open.feishu.cn/open-apis/cardkit/v1/cards")
assert create["json"] == {"type": "card_json", "data": '{"schema": "2.0"}'}
assert content["method"] == "PUT"
assert content["url"].endswith("/open-apis/cardkit/v1/cards/c1/elements/body/content")
assert content["json"] == {"uuid": "u2", "content": "文本", "sequence": 2}
assert settings["method"] == "PATCH"
assert settings["url"].endswith("/open-apis/cardkit/v1/cards/c1/settings")
assert settings["json"] == {"settings": '{"config": {}}', "uuid": "u3", "sequence": 3}
def test_upload_image_and_file_use_multipart(tmp_path: Path):
"""
图片与文件以 multipart 上传,表单字段与文件内容正确。
"""
image = tmp_path / "poster.png"
image.write_bytes(b"\x89PNG")
audio = tmp_path / "voice.opus"
audio.write_bytes(b"OggS")
http = FakeHttp([_token()], [_ok({"image_key": "img_1"}), _ok({"file_key": "file_1"})])
client = _client(http)
assert client.upload_image(image).data == {"image_key": "img_1"}
assert client.upload_file(audio, "opus", "语音.opus", duration=1500).data == {"file_key": "file_1"}
image_call, file_call = http.calls
assert image_call["url"].endswith("/open-apis/im/v1/images")
assert image_call["data"] == {"image_type": "message"}
assert image_call["files"] == {"image": ("poster.png", b"\x89PNG")}
assert file_call["data"] == {"file_type": "opus", "file_name": "语音.opus", "duration": "1500"}
assert file_call["files"] == {"file": ("语音.opus", b"OggS")}
assert "json" not in image_call
def test_download_message_resource_returns_bytes_name_and_type():
"""
下载资源返回内容、Content-Disposition 文件名和 Content-Type。
"""
download = FakeResponse(
status_code=200,
headers={
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf",
},
content=b"%PDF",
)
http = FakeHttp([_token()], [download])
result = _client(http).download_message_resource("om_1", "file_v2", "file")
assert result == (b"%PDF", "报告.pdf", "application/pdf")
assert http.calls[0]["url"].endswith("/open-apis/im/v1/messages/om_1/resources/file_v2")
assert http.calls[0]["params"] == {"type": "file"}
def test_download_failure_returns_none():
"""
下载接口返回错误 JSON 时不把错误体当作文件内容。
"""
http = FakeHttp([_token()], [FakeResponse({"code": 234003, "msg": "file not found"}, status_code=400)])
assert _client(http).download_image("img_1") is None
@pytest.mark.parametrize(
("header", "expected"),
[
(None, None),
('attachment; filename="poster.jpg"', "poster.jpg"),
("attachment; filename=%E5%9B%BE.png", "图.png"),
# requests 以 latin-1 解码响应头时,UTF-8 原文需要还原。
('attachment; filename="' + "报告.pdf".encode("utf-8").decode("latin-1") + '"', "报告.pdf"),
],
)
def test_parse_file_name(header, expected):
"""
响应头中的文件名支持普通文本、百分号编码和错误解码后的 UTF-8。
"""
assert _parse_file_name(header) == expected
def test_response_bodies_are_json_strings_not_reencoded():
"""
content 等字段按调用方给出的 JSON 字符串原样发送,不做二次编码。
"""
http = FakeHttp([_token()], [_ok()])
content = json.dumps({"text": "你好"}, ensure_ascii=False)
_client(http).reply_message("om_1", "text", content, True, "u1")
assert http.calls[0]["json"]["content"] == content
assert http.calls[0]["json"]["reply_in_thread"] is True
def test_upload_retry_after_invalid_token_resends_full_content(tmp_path: Path):
"""
令牌失效重试时重新上传完整文件内容,而不是已读到末尾的空句柄。
"""
image = tmp_path / "poster.png"
image.write_bytes(b"\x89PNG-full")
invalid = FakeResponse({"code": 99991663, "msg": "Invalid access token"}, status_code=400)
http = FakeHttp([_token("stale"), _token("fresh")], [invalid, _ok({"image_key": "img_1"})])
assert _client(http).upload_image(image).data == {"image_key": "img_1"}
assert [c["files"] for c in http.calls] == [{"image": ("poster.png", b"\x89PNG-full")}] * 2