Skip to content

Commit 929a88c

Browse files
EstrellaXDclaudehappy-otter
committed
test: add comprehensive test suite for core business logic
Cover RSS engine, downloader, renamer, auth, notifications, search, config, API endpoints, and end-to-end integration flows. When all 210 tests pass, the program's key behavioral contracts are verified. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 0682cc7 commit 929a88c

16 files changed

Lines changed: 3085 additions & 1 deletion

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@
1414
- 数据库迁移机制重构:使用 `schema_version` 表替代仅依赖应用版本号的迁移策略
1515
- 启动时始终检查并执行未完成的迁移,防止迁移中断后无法恢复
1616

17+
### Tests
18+
19+
- 新增全面的测试套件,覆盖核心业务逻辑:
20+
- RSS 引擎测试:pull_rss、match_torrent、refresh_rss、add_rss 全流程
21+
- 下载客户端测试:init_downloader、set_rule、add_torrent(磁力/文件)、rename
22+
- 路径工具测试:save_path 生成、文件分类、is_ep 深度检查
23+
- 重命名器测试:gen_path 命名方法(pn/advance/none/subtitle)、单文件/集合重命名
24+
- 认证测试:JWT 创建/解码/验证、密码哈希、get_current_user
25+
- 通知测试:getClient 工厂、send_msg 成功/失败、poster 查询
26+
- 搜索测试:URL 构建、关键词清洗、special_url
27+
- 配置测试:默认值、序列化、迁移、环境变量覆盖
28+
- Bangumi API 测试:CRUD 端点 + 认证要求
29+
- RSS API 测试:CRUD/批量端点 + 刷新
30+
- 集成测试:RSS→下载全流程、重命名全流程、数据库一致性
31+
- 新增 `pytest-mock` 开发依赖
32+
- 新增共享测试 fixtures(`conftest.py`)和数据工厂(`factories.py`
33+
1734
---
1835

1936
# [3.1] - 2023-08

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
dev = [
3131
"pytest>=8.0.0",
3232
"pytest-asyncio>=0.23.0",
33+
"pytest-mock>=3.12.0",
3334
"ruff>=0.1.0",
3435
"black>=24.0.0",
3536
"pre-commit>=3.0.0",

backend/src/test/conftest.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Shared test fixtures for AutoBangumi test suite."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, patch
5+
6+
from sqlmodel import Session, SQLModel, create_engine
7+
8+
from module.models.config import Config
9+
10+
11+
# ---------------------------------------------------------------------------
12+
# Database Fixtures
13+
# ---------------------------------------------------------------------------
14+
15+
16+
@pytest.fixture
17+
def db_engine():
18+
"""Create an in-memory SQLite engine for testing."""
19+
engine = create_engine("sqlite://", echo=False)
20+
SQLModel.metadata.create_all(engine)
21+
yield engine
22+
SQLModel.metadata.drop_all(engine)
23+
24+
25+
@pytest.fixture
26+
def db_session(db_engine):
27+
"""Provide a fresh database session per test."""
28+
with Session(db_engine) as session:
29+
yield session
30+
31+
32+
# ---------------------------------------------------------------------------
33+
# Settings Fixtures
34+
# ---------------------------------------------------------------------------
35+
36+
37+
@pytest.fixture
38+
def test_settings():
39+
"""Provide a Config object with predictable test defaults."""
40+
return Config()
41+
42+
43+
@pytest.fixture
44+
def mock_settings(test_settings):
45+
"""Patch module.conf.settings globally with test defaults."""
46+
with patch("module.conf.settings", test_settings):
47+
with patch("module.conf.config.settings", test_settings):
48+
yield test_settings
49+
50+
51+
# ---------------------------------------------------------------------------
52+
# Download Client Mock
53+
# ---------------------------------------------------------------------------
54+
55+
56+
@pytest.fixture
57+
def mock_qb_client():
58+
"""Mock QbDownloader that simulates qBittorrent API responses."""
59+
client = AsyncMock()
60+
client.auth.return_value = True
61+
client.logout.return_value = None
62+
client.check_host.return_value = True
63+
client.torrents_info.return_value = []
64+
client.torrents_files.return_value = []
65+
client.torrents_rename_file.return_value = True
66+
client.add_torrents.return_value = True
67+
client.torrents_delete.return_value = None
68+
client.torrents_pause.return_value = None
69+
client.torrents_resume.return_value = None
70+
client.rss_set_rule.return_value = None
71+
client.prefs_init.return_value = None
72+
client.add_category.return_value = None
73+
client.get_app_prefs.return_value = {"save_path": "/downloads"}
74+
client.move_torrent.return_value = None
75+
client.rss_add_feed.return_value = None
76+
client.rss_remove_item.return_value = None
77+
client.rss_get_feeds.return_value = {}
78+
client.get_download_rule.return_value = {}
79+
client.get_torrent_path.return_value = "/downloads/Bangumi"
80+
client.set_category.return_value = None
81+
client.remove_rule.return_value = None
82+
return client

backend/src/test/factories.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Test data factories for creating model instances with sensible defaults."""
2+
3+
from module.models import Bangumi, RSSItem, Torrent
4+
5+
6+
def make_bangumi(**overrides) -> Bangumi:
7+
"""Create a Bangumi instance with sensible test defaults."""
8+
defaults = dict(
9+
official_title="Test Anime",
10+
year="2024",
11+
title_raw="Test Anime Raw",
12+
season=1,
13+
season_raw="",
14+
group_name="TestGroup",
15+
dpi="1080p",
16+
source="Web",
17+
subtitle="CHT",
18+
eps_collect=False,
19+
offset=0,
20+
filter="720",
21+
rss_link="https://mikanani.me/RSS/test",
22+
poster_link="/test/poster.jpg",
23+
added=True,
24+
rule_name="[TestGroup] Test Anime S1",
25+
save_path="/downloads/Bangumi/Test Anime (2024)/Season 1",
26+
deleted=False,
27+
)
28+
defaults.update(overrides)
29+
return Bangumi(**defaults)
30+
31+
32+
def make_torrent(**overrides) -> Torrent:
33+
"""Create a Torrent instance with sensible test defaults."""
34+
defaults = dict(
35+
name="[TestGroup] Test Anime Raw - 01 [1080p].mkv",
36+
url="https://example.com/test.torrent",
37+
homepage="https://mikanani.me/Home/Episode/test",
38+
downloaded=False,
39+
)
40+
defaults.update(overrides)
41+
return Torrent(**defaults)
42+
43+
44+
def make_rss_item(**overrides) -> RSSItem:
45+
"""Create an RSSItem instance with sensible test defaults."""
46+
defaults = dict(
47+
name="Test RSS Feed",
48+
url="https://mikanani.me/RSS/MyBangumi?token=test",
49+
aggregate=True,
50+
parser="mikan",
51+
enabled=True,
52+
)
53+
defaults.update(overrides)
54+
return RSSItem(**defaults)
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Tests for Bangumi API endpoints."""
2+
3+
import pytest
4+
from unittest.mock import patch, MagicMock, AsyncMock
5+
from datetime import timedelta
6+
7+
from fastapi import FastAPI
8+
from fastapi.testclient import TestClient
9+
10+
from module.api import v1
11+
from module.models import Bangumi, BangumiUpdate, ResponseModel
12+
from module.security.api import get_current_user, active_user
13+
from module.security.jwt import create_access_token
14+
15+
from test.factories import make_bangumi
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Fixtures
20+
# ---------------------------------------------------------------------------
21+
22+
23+
@pytest.fixture
24+
def app():
25+
"""Create a FastAPI app with v1 routes for testing."""
26+
app = FastAPI()
27+
app.include_router(v1, prefix="/api")
28+
return app
29+
30+
31+
@pytest.fixture
32+
def authed_client(app):
33+
"""TestClient with auth dependency overridden."""
34+
async def mock_user():
35+
return "testuser"
36+
37+
app.dependency_overrides[get_current_user] = mock_user
38+
client = TestClient(app)
39+
yield client
40+
app.dependency_overrides.clear()
41+
42+
43+
@pytest.fixture
44+
def unauthed_client(app):
45+
"""TestClient without auth (no override)."""
46+
return TestClient(app)
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Auth requirement
51+
# ---------------------------------------------------------------------------
52+
53+
54+
class TestAuthRequired:
55+
def test_get_all_unauthorized(self, unauthed_client):
56+
"""GET /bangumi/get/all without auth returns 401."""
57+
response = unauthed_client.get("/api/v1/bangumi/get/all")
58+
assert response.status_code == 401
59+
60+
def test_get_by_id_unauthorized(self, unauthed_client):
61+
"""GET /bangumi/get/1 without auth returns 401."""
62+
response = unauthed_client.get("/api/v1/bangumi/get/1")
63+
assert response.status_code == 401
64+
65+
def test_delete_unauthorized(self, unauthed_client):
66+
"""DELETE /bangumi/delete/1 without auth returns 401."""
67+
response = unauthed_client.delete("/api/v1/bangumi/delete/1")
68+
assert response.status_code == 401
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# GET endpoints
73+
# ---------------------------------------------------------------------------
74+
75+
76+
class TestGetBangumi:
77+
def test_get_all(self, authed_client):
78+
"""GET /bangumi/get/all returns list of Bangumi."""
79+
mock_bangumi = [make_bangumi(id=1), make_bangumi(id=2, title_raw="Other")]
80+
with patch("module.api.bangumi.TorrentManager") as MockManager:
81+
mock_mgr = MagicMock()
82+
mock_mgr.bangumi.search_all.return_value = mock_bangumi
83+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
84+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
85+
86+
response = authed_client.get("/api/v1/bangumi/get/all")
87+
88+
assert response.status_code == 200
89+
data = response.json()
90+
assert len(data) == 2
91+
92+
def test_get_by_id(self, authed_client):
93+
"""GET /bangumi/get/{id} returns single Bangumi."""
94+
bangumi = make_bangumi(id=1, official_title="Found Anime")
95+
with patch("module.api.bangumi.TorrentManager") as MockManager:
96+
mock_mgr = MagicMock()
97+
mock_mgr.search_one.return_value = bangumi
98+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
99+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
100+
101+
response = authed_client.get("/api/v1/bangumi/get/1")
102+
103+
assert response.status_code == 200
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# PATCH/UPDATE endpoints
108+
# ---------------------------------------------------------------------------
109+
110+
111+
class TestUpdateBangumi:
112+
def test_update_success(self, authed_client):
113+
"""PATCH /bangumi/update/{id} updates and returns success."""
114+
resp_model = ResponseModel(
115+
status=True, status_code=200, msg_en="Updated.", msg_zh="已更新。"
116+
)
117+
with patch("module.api.bangumi.TorrentManager") as MockManager:
118+
mock_mgr = MagicMock()
119+
mock_mgr.update_rule = AsyncMock(return_value=resp_model)
120+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
121+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
122+
123+
# BangumiUpdate requires all fields
124+
update_data = {
125+
"official_title": "New Title",
126+
"title_raw": "new_raw",
127+
"season": 1,
128+
"year": "2024",
129+
"season_raw": "",
130+
"group_name": "Group",
131+
"dpi": "1080p",
132+
"source": "Web",
133+
"subtitle": "CHT",
134+
"eps_collect": False,
135+
"offset": 0,
136+
"filter": "720",
137+
"rss_link": "https://test.com/rss",
138+
"poster_link": None,
139+
"added": True,
140+
"rule_name": None,
141+
"save_path": None,
142+
"deleted": False,
143+
}
144+
response = authed_client.patch(
145+
"/api/v1/bangumi/update/1",
146+
json=update_data,
147+
)
148+
149+
assert response.status_code == 200
150+
151+
152+
# ---------------------------------------------------------------------------
153+
# DELETE endpoints
154+
# ---------------------------------------------------------------------------
155+
156+
157+
class TestDeleteBangumi:
158+
def test_delete_success(self, authed_client):
159+
"""DELETE /bangumi/delete/{id} removes bangumi."""
160+
resp_model = ResponseModel(
161+
status=True, status_code=200, msg_en="Deleted.", msg_zh="已删除。"
162+
)
163+
with patch("module.api.bangumi.TorrentManager") as MockManager:
164+
mock_mgr = MagicMock()
165+
mock_mgr.delete_rule = AsyncMock(return_value=resp_model)
166+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
167+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
168+
169+
response = authed_client.delete("/api/v1/bangumi/delete/1")
170+
171+
assert response.status_code == 200
172+
173+
def test_disable_rule(self, authed_client):
174+
"""DELETE /bangumi/disable/{id} marks as deleted."""
175+
resp_model = ResponseModel(
176+
status=True, status_code=200, msg_en="Disabled.", msg_zh="已禁用。"
177+
)
178+
with patch("module.api.bangumi.TorrentManager") as MockManager:
179+
mock_mgr = MagicMock()
180+
mock_mgr.disable_rule = AsyncMock(return_value=resp_model)
181+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
182+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
183+
184+
response = authed_client.delete("/api/v1/bangumi/disable/1")
185+
186+
assert response.status_code == 200
187+
188+
def test_enable_rule(self, authed_client):
189+
"""GET /bangumi/enable/{id} re-enables rule."""
190+
resp_model = ResponseModel(
191+
status=True, status_code=200, msg_en="Enabled.", msg_zh="已启用。"
192+
)
193+
with patch("module.api.bangumi.TorrentManager") as MockManager:
194+
mock_mgr = MagicMock()
195+
mock_mgr.enable_rule.return_value = resp_model
196+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
197+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
198+
199+
response = authed_client.get("/api/v1/bangumi/enable/1")
200+
201+
assert response.status_code == 200
202+
203+
204+
# ---------------------------------------------------------------------------
205+
# Reset
206+
# ---------------------------------------------------------------------------
207+
208+
209+
class TestResetBangumi:
210+
def test_reset_all(self, authed_client):
211+
"""GET /bangumi/reset/all deletes all bangumi."""
212+
with patch("module.api.bangumi.TorrentManager") as MockManager:
213+
mock_mgr = MagicMock()
214+
mock_mgr.bangumi.delete_all.return_value = None
215+
MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)
216+
MockManager.return_value.__exit__ = MagicMock(return_value=False)
217+
218+
response = authed_client.get("/api/v1/bangumi/reset/all")
219+
220+
assert response.status_code == 200

0 commit comments

Comments
 (0)