fork
This commit is contained in:
107
tests/test_calendar_service.py
Normal file
107
tests/test_calendar_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import src.services.calendar as calendar_module
|
||||
from src.services import BangumiApiError, CalendarService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service() -> CalendarService:
|
||||
return CalendarService(access_token="token", user_agent="ua")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_cache_hit(service: CalendarService) -> None:
|
||||
payload = [{"weekday": {"id": 1}, "items": [{"id": 1, "name": "A"}]}]
|
||||
service._request = AsyncMock(return_value=payload)
|
||||
|
||||
first = await service.get_calendar()
|
||||
second = await service.get_calendar()
|
||||
|
||||
assert first == second
|
||||
assert service._request.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_cache_expired_refresh(
|
||||
service: CalendarService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
now = 1_000_000.0
|
||||
|
||||
def fake_time() -> float:
|
||||
return now
|
||||
|
||||
monkeypatch.setattr(calendar_module.time, "time", fake_time)
|
||||
|
||||
service._request = AsyncMock(
|
||||
side_effect=[
|
||||
[{"weekday": {"id": 1}, "items": []}],
|
||||
[{"weekday": {"id": 2}, "items": []}],
|
||||
]
|
||||
)
|
||||
|
||||
first = await service.get_calendar()
|
||||
now += service.CALENDAR_CACHE_TTL_SECONDS + 1
|
||||
second = await service.get_calendar()
|
||||
|
||||
assert first != second
|
||||
assert service._request.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_cache_returns_deepcopy(service: CalendarService) -> None:
|
||||
payload = [{"weekday": {"id": 1}, "items": []}]
|
||||
service._request = AsyncMock(return_value=payload)
|
||||
|
||||
first = await service.get_calendar()
|
||||
first[0]["weekday"]["id"] = 7
|
||||
second = await service.get_calendar()
|
||||
|
||||
assert second[0]["weekday"]["id"] == 1
|
||||
assert service._request.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_cache_refresh_failed_fallback_stale(
|
||||
service: CalendarService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
now = 2_000_000.0
|
||||
|
||||
def fake_time() -> float:
|
||||
return now
|
||||
|
||||
monkeypatch.setattr(calendar_module.time, "time", fake_time)
|
||||
|
||||
service._request = AsyncMock(
|
||||
side_effect=[
|
||||
[{"weekday": {"id": 1}, "items": [{"id": 1}]}],
|
||||
BangumiApiError("boom"),
|
||||
]
|
||||
)
|
||||
|
||||
first = await service.get_calendar()
|
||||
now += service.CALENDAR_CACHE_TTL_SECONDS + 1
|
||||
second = await service.get_calendar()
|
||||
|
||||
assert second == first
|
||||
assert service._request.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_cache_concurrent_single_refresh(
|
||||
service: CalendarService,
|
||||
) -> None:
|
||||
payload = [{"weekday": {"id": 3}, "items": []}]
|
||||
|
||||
async def slow_fetch(*args: object, **kwargs: object) -> list[dict[str, object]]:
|
||||
await asyncio.sleep(0.05)
|
||||
return payload
|
||||
|
||||
service._request = AsyncMock(side_effect=slow_fetch)
|
||||
|
||||
first, second = await asyncio.gather(service.get_calendar(), service.get_calendar())
|
||||
|
||||
assert first == second
|
||||
assert service._request.await_count == 1
|
||||
70
tests/test_search_service.py
Normal file
70
tests/test_search_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
|
||||
from src.services import SearchService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service() -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.search_subjects = AsyncMock()
|
||||
service.get_subject_details = AsyncMock()
|
||||
service.get_subject_episodes = AsyncMock()
|
||||
service.get_calendar = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_manager() -> MagicMock:
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_render_server_url.return_value = "https://api.unitedpooh.top/rpc"
|
||||
config_manager.get_max_retries.return_value = 1
|
||||
return config_manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_calendar_success(
|
||||
mock_service: MagicMock, mock_config_manager: MagicMock
|
||||
) -> None:
|
||||
# 准备 Mock 数据
|
||||
mock_service.get_calendar.return_value = [{"weekday": {"id": 1}, "items": []}]
|
||||
|
||||
search_service = SearchService(
|
||||
service=mock_service, config_manager=mock_config_manager
|
||||
)
|
||||
|
||||
# Mock 渲染器,避免进入模板渲染逻辑
|
||||
search_service.calendar_renderer.render_calendar = AsyncMock(
|
||||
return_value="fake_base64"
|
||||
)
|
||||
|
||||
event = MagicMock(spec=AstrMessageEvent)
|
||||
event.chain_result = MagicMock(side_effect=lambda x: x)
|
||||
|
||||
results: list[object] = []
|
||||
async for res in search_service.handle_calendar(event):
|
||||
results.append(res)
|
||||
|
||||
assert len(results) > 0
|
||||
mock_service.get_calendar.assert_called_once()
|
||||
event.chain_result.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_subject_search_no_query(
|
||||
mock_service: MagicMock, mock_config_manager: MagicMock
|
||||
) -> None:
|
||||
search_service = SearchService(
|
||||
service=mock_service, config_manager=mock_config_manager
|
||||
)
|
||||
event = MagicMock(spec=AstrMessageEvent)
|
||||
event.plain_result = MagicMock(side_effect=lambda x: x)
|
||||
|
||||
results: list[object] = []
|
||||
async for res in search_service.handle_subject_search(event, query=""):
|
||||
results.append(res)
|
||||
|
||||
assert len(results) > 0
|
||||
assert "❌ 请提供搜索关键词" in str(results[0])
|
||||
72
tests/test_subject_renderer.py
Normal file
72
tests/test_subject_renderer.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from src.render import SubjectRenderer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_subject_card_success() -> None:
|
||||
# 准备测试数据
|
||||
subject_data = {
|
||||
"date": "2026-01-11",
|
||||
"platform": "TV",
|
||||
"images": {
|
||||
"small": "https://lain.bgm.tv/r/200/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||
"grid": "https://lain.bgm.tv/r/100/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||
"large": "https://lain.bgm.tv/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||
"medium": "https://lain.bgm.tv/r/800/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||
"common": "https://lain.bgm.tv/r/400/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||
},
|
||||
"summary": "总是活力充沛,却又很在意周遭目光的女孩:铃木实优\r\n以及个性文静,却能清楚表达自己意见的男生:谷悠介\r\n\r\n本次故事将讲述这两人的生活点滴。铃木喜欢着谷,却一直无法鼓起勇气告白。直到某天,两人放学回家时走在同一条路上并牵起了手。借由该契机,两人相互倾诉对彼此的好感并开始了交往。同学们虽然感到讶异,但也都很支持两人的恋情。\r\n这部恋爱喜剧描写的,正是这对个性截然相反的两人,在彼此尊重之下慢慢加深互相的理解,并与朋友们一同度过的校园生活点滴。如此温暖的故事就此开幕!\r\n\r\n\r\n\r\n[简介原文]\r\nいつも元気いっぱいだけど周りの目を気にしてしまう女子・鈴木と、\r\n物静かだけど自分の意見をしっかり言える男子・谷。\r\n正反対な二人が误解や勘違いをしながらもお互いを尊重し、\r\nゆっくりと理解を深めていく姿と、友人たちとの学校生活を描くラブコメディ。",
|
||||
"name": "正反対な君と僕",
|
||||
"name_cn": "相反的你和我",
|
||||
"tags": [
|
||||
{"name": "恋爱", "count": 1356},
|
||||
{"name": "校园", "count": 1071},
|
||||
{"name": "2026年1月", "count": 1033},
|
||||
{"name": "漫画改", "count": 823},
|
||||
],
|
||||
"infobox": [
|
||||
{"key": "中文名", "value": "相反的你和我"},
|
||||
{"key": "别名", "value": [{"v": "正相反的你与我"}]},
|
||||
{"key": "话数", "value": "12"},
|
||||
{"key": "放送开始", "value": "2026年1月11日"},
|
||||
],
|
||||
"total_episodes": 12,
|
||||
"id": 525565,
|
||||
"type": 2,
|
||||
"rating": {
|
||||
"rank": 677,
|
||||
"total": 2517,
|
||||
"count": {
|
||||
"1": 6,
|
||||
"2": 3,
|
||||
"3": 7,
|
||||
"4": 13,
|
||||
"5": 40,
|
||||
"6": 167,
|
||||
"7": 753,
|
||||
"8": 1234,
|
||||
"9": 194,
|
||||
"10": 100,
|
||||
},
|
||||
"score": 7.6,
|
||||
},
|
||||
}
|
||||
|
||||
renderer = SubjectRenderer()
|
||||
|
||||
# 运行渲染器
|
||||
base64_image = await renderer.render_subject_card(
|
||||
rpc_url="https://api.unitedpooh.top/rpc",
|
||||
data=subject_data,
|
||||
headless=True,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert base64_image is not None, "[-] 渲染失败,未返回 Base64 字符串"
|
||||
assert isinstance(base64_image, str), "返回值应为 Base64 字符串"
|
||||
assert len(base64_image) > 100, "Base64 字符串过短"
|
||||
|
||||
logger.info(f"[+] 渲染成功!图片长度: {len(base64_image)} 字符")
|
||||
206
tests/test_subscription_service.py
Normal file
206
tests/test_subscription_service.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services import SubscriptionService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo() -> MagicMock:
|
||||
repo = MagicMock()
|
||||
repo.subscribe_subject = MagicMock(return_value=True)
|
||||
repo.remove_subscription = MagicMock(return_value=True)
|
||||
repo.find_group_subscription_candidates = MagicMock(return_value=[])
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service() -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.search_subjects = AsyncMock()
|
||||
service.get_subject_details = AsyncMock()
|
||||
service.get_calendar = AsyncMock()
|
||||
service.get_latest_episode = AsyncMock()
|
||||
service.get_subject_base64image = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_success(mock_repo, mock_service) -> None:
|
||||
mock_service.search_subjects.return_value = {"data": [{"id": 123}]}
|
||||
mock_service.get_subject_details.return_value = {
|
||||
"id": 123,
|
||||
"name": "Test Anime",
|
||||
"name_cn": "测试番剧",
|
||||
"date": "2024-01-01",
|
||||
"eps": 12,
|
||||
}
|
||||
mock_service.get_calendar.return_value = [{"items": [{"id": 123}]}]
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.subscribe("group_1", "Test Anime")
|
||||
|
||||
assert "成功订阅《测试番剧》" in result
|
||||
mock_repo.subscribe_subject.assert_called_once_with(
|
||||
group_id="group_1",
|
||||
subject_id="123",
|
||||
name="测试番剧",
|
||||
air_date="2024-01-01",
|
||||
total_episodes=12,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_local_single_match_success(mock_repo, mock_service) -> None:
|
||||
mock_repo.find_group_subscription_candidates.return_value = [
|
||||
SimpleNamespace(subject_id="123", name="测试番剧")
|
||||
]
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.unsubscribe("group_1", "测")
|
||||
|
||||
assert "已成功取消订阅《测试番剧》" in result
|
||||
mock_repo.find_group_subscription_candidates.assert_called_once_with(
|
||||
group_id="group_1", keyword="测", limit=6
|
||||
)
|
||||
mock_repo.remove_subscription.assert_called_once_with("group_1", "123")
|
||||
mock_service.search_subjects.assert_not_called()
|
||||
mock_service.get_subject_details.assert_not_called()
|
||||
mock_service.get_calendar.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_local_no_match(mock_repo, mock_service) -> None:
|
||||
mock_repo.find_group_subscription_candidates.return_value = []
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.unsubscribe("group_1", "不存在")
|
||||
|
||||
assert "未找到与「不存在」匹配的本群订阅番剧" in result
|
||||
mock_repo.remove_subscription.assert_not_called()
|
||||
mock_service.search_subjects.assert_not_called()
|
||||
mock_service.get_subject_details.assert_not_called()
|
||||
mock_service.get_calendar.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_local_multi_match_returns_candidates(
|
||||
mock_repo, mock_service
|
||||
) -> None:
|
||||
mock_repo.find_group_subscription_candidates.return_value = [
|
||||
SimpleNamespace(subject_id="1", name="进击的巨人"),
|
||||
SimpleNamespace(subject_id="2", name="进击!巨人中学"),
|
||||
SimpleNamespace(subject_id="3", name="巨人族的新娘"),
|
||||
]
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.unsubscribe("group_1", "巨人")
|
||||
|
||||
assert "匹配到多个已订阅番剧" in result
|
||||
assert "1. 进击的巨人 (ID: 1)" in result
|
||||
assert "2. 进击!巨人中学 (ID: 2)" in result
|
||||
assert "3. 巨人族的新娘 (ID: 3)" in result
|
||||
mock_repo.remove_subscription.assert_not_called()
|
||||
mock_service.search_subjects.assert_not_called()
|
||||
mock_service.get_subject_details.assert_not_called()
|
||||
mock_service.get_calendar.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_local_remove_failed(mock_repo, mock_service) -> None:
|
||||
mock_repo.find_group_subscription_candidates.return_value = [
|
||||
SimpleNamespace(subject_id="123", name="测试番剧")
|
||||
]
|
||||
mock_repo.remove_subscription.return_value = False
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.unsubscribe("group_1", "测")
|
||||
|
||||
assert "取消订阅失败:你可能并没有订阅《测试番剧》" in result
|
||||
mock_repo.remove_subscription.assert_called_once_with("group_1", "123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_subscribe_candidates_multi_match(mock_repo, mock_service) -> None:
|
||||
mock_service.search_subjects.return_value = {
|
||||
"data": [
|
||||
{"id": 1, "name_cn": "进击的巨人"},
|
||||
{"id": 2, "name": "进击!巨人中学"},
|
||||
{"id": 1, "name_cn": "进击的巨人"},
|
||||
{"name_cn": "无ID条目"},
|
||||
]
|
||||
}
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
error_msg, candidates = await sub_service.get_subscribe_candidates("巨人", 5)
|
||||
|
||||
assert error_msg is None
|
||||
assert candidates == [
|
||||
{"subject_id": "1", "name": "进击的巨人"},
|
||||
{"subject_id": "2", "name": "进击!巨人中学"},
|
||||
]
|
||||
mock_service.search_subjects.assert_awaited_once_with(
|
||||
keyword="巨人",
|
||||
limit=5,
|
||||
subject_type=[2],
|
||||
subject_tags=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_by_subject_id_success(mock_repo, mock_service) -> None:
|
||||
mock_service.get_subject_details.return_value = {
|
||||
"id": 456,
|
||||
"name": "Test Name",
|
||||
"name_cn": "测试番剧2",
|
||||
"date": "2025-01-01",
|
||||
"eps": 24,
|
||||
}
|
||||
mock_service.get_calendar.return_value = [{"items": [{"id": 456}]}]
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.subscribe_by_subject_id("group_1", "456")
|
||||
|
||||
assert "✅ 成功订阅《测试番剧2》" in result
|
||||
mock_repo.subscribe_subject.assert_called_once_with(
|
||||
group_id="group_1",
|
||||
subject_id="456",
|
||||
name="测试番剧2",
|
||||
air_date="2025-01-01",
|
||||
total_episodes=24,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_by_subject_id_not_in_calendar(mock_repo, mock_service) -> None:
|
||||
mock_service.get_subject_details.return_value = {
|
||||
"id": 789,
|
||||
"name": "Not In Calendar",
|
||||
"name_cn": "未放送番剧",
|
||||
"date": "2025-06-01",
|
||||
"eps": 12,
|
||||
}
|
||||
mock_service.get_calendar.return_value = [{"items": [{"id": 456}]}]
|
||||
|
||||
sub_service = SubscriptionService(
|
||||
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||
)
|
||||
result = await sub_service.subscribe_by_subject_id("group_1", "789")
|
||||
|
||||
assert "不在当前的每日放送列表中" in result
|
||||
mock_repo.subscribe_subject.assert_not_called()
|
||||
Reference in New Issue
Block a user