fix: 修复主程序卡死与下载器集成,新增真实数据测试

- LookingWebMain: 强制 IPv4 避免 IPv6 不可达挂起;requests 加超时防卡死
- get_seed_from_element_detail: 修复限流页崩溃与 favicon 误作来源 URL,改用 canonical/传入 URL
- QbittorrentDownloader: 修复 main() 中模块/类误用导致的 download_by_seed AttributeError;请求加超时
- MySqlService: 修复数据库不可达时 query/execute/close 的 None 崩溃,close 后置空 connection
- 新增 cf_session: Cloudflare 人机认证 cookie 管理 + 限流退避重试 + 自动弹浏览器刷新验证
- 新增 refresh_cf_cookies.py: 弹出浏览器手动验证并保存 cf_clearance
- tests/: 真实网络/数据库测试(get_page、seed_is_exist、cf_session)+ run_tests.py runner
- 清理无用脚本(final_test2/verify_replace/REPLACEMENT_INFO),.idea 与 cookie 文件入 .gitignore

注: test_qbittorrent_downloader.py 11 个用例为既有失败(mock 目标 session 为实例属性,与实现错配),未在本次范围
This commit is contained in:
2026-08-30 12:24:26 +08:00
parent f8e8fd38b4
commit 01827224d6
14 changed files with 1327 additions and 18 deletions
+61
View File
@@ -0,0 +1,61 @@
# 测试说明
## 测试文件
### MySQLDatabase 测试
- `test_database.py` - MySQLDatabase 类测试
- 测试数据库配置
- 测试创建连接对象
- 测试连接对象属性和方法
- 测试关闭连接
### seed_is_exist 测试
- `test_seed_is_exist.py` - seed_is_exist 功能真实数据库测试
- 首次运行自动创建 `LookingWeb` 库和 `seeds_info` 表(幂等)
- 测试种子不存在 → False
- 真实插入种子后 → True
- 多个种子均识别为存在
- 删除后再查询 → False
- 数据库不可达 → False(不抛异常)
- 测试数据测后自动清理
### qBittorrent 下载器测试
- `test_qbittorrent_downloader.py` - qBittorrent 下载器单元测试
- 测试初始化(带/不带认证)
- 测试添加下载任务(成功/失败/异常)
- 测试种子信息下载
- 测试带选项的下载(保存路径、分类、标签)
- 测试连接测试
- 测试获取下载列表
- 注意:当前 11/12 用例因测试 mock 目标与实现不一致(`QbittorrentDownloader.session` 是实例属性而非模块属性)而失败,属既有问题,待修复。
## 运行测试
```bash
# 安装 pytest
uv pip install pytest
# 运行所有测试
uv run python -m pytest tests/ -v
# 运行 MySQLDatabase 测试
uv run python -m pytest tests/test_database.py -v
# 运行 qBittorrent 测试
uv run python -m pytest tests/test_qbittorrent_downloader.py -v
```
## 环境要求
- Python 3.7+
- requests
- loguru
- beautifulsoup4
- pymysql
- pytest
## 测试覆盖
- MySQLDatabase: 4 个测试用例
- seed_is_exist: 5 个测试用例(真实数据库)
- qBittorrentDownloader: 12 个测试用例(11 个待修复)
- 总计: 25 个测试用例
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
运行 tests/ 目录下全部测试
运行命令:
uv run python tests/run_tests.py
"""
import pytest
import sys
import os
def main():
"""运行测试"""
# 本脚本位于 tests/ 目录,直接运行该目录下所有测试
script_dir = os.path.dirname(os.path.abspath(__file__))
tests_dir = script_dir
os.chdir(script_dir)
# 配置 pytest
exit_code = pytest.main([
str(tests_dir),
"-v",
"--tb=short",
"--log-cli-level=INFO",
"-W",
"ignore::DeprecationWarning"
])
return exit_code
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
"""
测试 cf_sessionCloudflare 限流/人机认证识别与自动重试逻辑
"""
import sys
import os
import pytest
from bs4 import BeautifulSoup
from loguru import logger
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import cf_session
class TestRateLimitDetection:
"""限流/人机认证页识别(纯函数测试)"""
def setup_method(self):
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_detects_429_rate_limit_page(self):
"""429 限流页(Too Many Requests)应被识别"""
soup = BeautifulSoup(
'<html><head><title>Too Many Requests</title></head>'
'<body><div class="cf-error-title">429</div></body></html>',
'html.parser')
assert cf_session.is_rate_limited(soup) is True
def test_detects_403_js_challenge_page(self):
"""403 JS 挑战页(Just a moment)应被识别"""
soup = BeautifulSoup(
'<html><head><title>Just a moment...</title></head>'
'<body><script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></body></html>',
'html.parser')
assert cf_session.is_rate_limited(soup) is True
def test_detects_403_attention_required_page(self):
"""403 阻止页(Attention Required)应被识别"""
soup = BeautifulSoup(
'<html><head><title>Attention Required! | Cloudflare</title></head><body>x</body></html>',
'html.parser')
assert cf_session.is_rate_limited(soup) is True
def test_normal_page_not_rate_limited(self):
"""正常页面不应被误判"""
soup = BeautifulSoup(
'<html><body><div class="card-body">可下</div></body></html>',
'html.parser')
assert cf_session.is_rate_limited(soup) is False
def test_none_not_rate_limited(self):
"""None 页面不应被误判"""
assert cf_session.is_rate_limited(None) is False
class TestParseCookies:
"""agent-browser cookies 输出解析"""
def test_parse_name_value_lines(self):
stdout = ("cf_clearance=abc.def-123\n"
"bnState_x=%7B%22a%22%3A1%7D\n"
"# comment line\n")
cookies = cf_session._parse_cookies(stdout)
assert cookies == {
"cf_clearance": "abc.def-123",
"bnState_x": "%7B%22a%22%3A1%7D",
}
def test_parse_empty(self):
assert cf_session._parse_cookies("") == {}
class TestFetchWithRetry:
"""自动重试的真实请求验证"""
def setup_method(self):
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_fetch_real_url_ok(self):
"""真实 URL 正常返回 (soup, 'ok')"""
url = "https://freejavbt.com/censored/filter?c=212&page=1"
soup, status = cf_session.fetch_with_retry(url, (10, 60), retries=2, retry_delay=2)
assert status == "ok"
assert soup is not None
assert len(soup.find_all("div", {"class": "card-body"})) > 0
def test_fetch_network_error(self):
"""不可达 URL 返回 (None, 'error')"""
soup, status = cf_session.fetch_with_retry(
"http://invalid-domain-for-test-12345.invalid", (5, 10), retries=2, retry_delay=1)
assert status == "error"
assert soup is None
def run_tests():
"""运行所有测试"""
pytest.main([
__file__,
"-v",
"--tb=short",
"--log-cli-level=INFO"
])
if __name__ == "__main__":
run_tests()
+75
View File
@@ -0,0 +1,75 @@
"""
测试 MySQLDatabase 类
"""
import pytest
import pymysql
from loguru import logger
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from dataService.MySqlService import MySQLDatabase, DatabaseConfig
class TestMySQLDatabase:
"""MySQLDatabase 类测试"""
def setup_method(self):
"""每个测试方法前的设置"""
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_database_config(self):
"""测试数据库配置"""
assert DatabaseConfig.HOST == "192.168.123.199"
assert DatabaseConfig.PORT == 3306
assert DatabaseConfig.USER == "root"
assert DatabaseConfig.PASSWORD == "GUOxy5157"
assert DatabaseConfig.DB == "LookingWeb"
logger.info("数据库配置正确")
def test_create_connection_exists(self):
"""测试创建连接对象"""
connection = MySQLDatabase.create_connection()
assert connection is not None
logger.info("数据库连接对象创建成功")
connection.close()
def test_connection_has_attributes(self):
"""测试连接对象有必需的属性"""
connection = MySQLDatabase.create_connection()
assert hasattr(connection, 'host')
assert hasattr(connection, 'port')
assert hasattr(connection, 'user')
assert hasattr(connection, 'password')
assert hasattr(connection, 'db')
assert hasattr(connection, 'connection')
assert hasattr(connection, 'connect')
assert hasattr(connection, 'query')
assert hasattr(connection, 'execute')
assert hasattr(connection, 'close')
logger.info("连接对象具有所有必需的属性和方法")
connection.close()
def test_connection_close(self):
"""测试关闭连接"""
connection = MySQLDatabase.create_connection()
connection.close()
assert connection.connection is None
logger.info("关闭连接成功")
def run_tests():
"""运行所有测试"""
pytest.main([
__file__,
"-v",
"--tb=short",
"--log-cli-level=INFO"
])
if __name__ == "__main__":
run_tests()
+131
View File
@@ -0,0 +1,131 @@
"""
使用主程序调用时会使用的真实 URL 测试 get_page 功能
测试说明:
- 调用真实的 get_page 函数(不 mock requests
- 使用主程序 main() 中实际使用的 URL 获取真实页面数据
- 当前仅覆盖 get_page 功能
注意事项:
- 依赖真实网络,站点(freejavbt.com)响应可能较慢或被 Cloudflare 限流(429),
测试中对限流做了重试与容忍处理。
- freejavbt.com 同时解析出 IPv4 和 IPv6 地址,而部分环境 IPv6 不可达会导致
requests 长时间挂起,因此在导入 requests 之前强制走 IPv4。
"""
import sys
import os
import time
import socket
socket.has_ipv6 = False
import pytest
from bs4 import BeautifulSoup
from loguru import logger
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from LookingWebMain import get_page
# 主程序 main() 中实际使用的 URL 模板
# LookingWebMain.main: for page in range(5): url = f"https://freejavbt.com/censored/filter?c=212&page={page+1}"
MAIN_PAGE_URL_TEMPLATE = "https://freejavbt.com/censored/filter?c=212&page={page}"
# get_page 不检查 HTTP 状态码,Cloudflare 限流时也会返回解析后的
# "Too Many Requests" 页面,这里通过页面文本识别限流。
RATE_LIMIT_MARK = "Too Many Requests"
def is_rate_limited(page_content):
"""判断页面是否为 Cloudflare 限流响应(真实数据的一部分)"""
return page_content is not None and RATE_LIMIT_MARK in page_content.text
def get_page_with_retry(url, retries=3, sleep_seconds=5):
"""
调用真实的 get_page 获取页面,遇到限流时短暂等待后重试。
返回 (结果, 是否限流过)。失败时结果可能为 None。
"""
result = None
for attempt in range(1, retries + 1):
result = get_page(url)
if not is_rate_limited(result):
return result, attempt > 1
logger.warning("页面被限流,等待 {} 秒后重试 (第 {}/{} 次): {}", sleep_seconds, attempt, retries, url)
if attempt < retries:
time.sleep(sleep_seconds)
return result, True
class TestGetPage:
"""使用主程序真实 URL 和真实数据测试 get_page 功能"""
def setup_method(self):
"""每个测试方法前的设置"""
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_get_page_success_with_main_url(self):
"""使用主程序第 1 页的 URL 真实请求并解析页面"""
url = MAIN_PAGE_URL_TEMPLATE.format(page=1)
logger.info("使用主程序 URL 获取真实页面: {}", url)
result, _ = get_page_with_retry(url)
assert result is not None, f"真实请求失败: {url}"
assert isinstance(result, BeautifulSoup)
logger.info("真实页面获取成功,标题: {}", result.title.string if result.title else "无标题")
def test_get_page_returns_real_content(self):
"""真实页面数据应包含主程序依赖的卡片结构(card-body)"""
url = MAIN_PAGE_URL_TEMPLATE.format(page=1)
result, _ = get_page_with_retry(url)
assert result is not None, f"真实请求失败: {url}"
assert not is_rate_limited(result), f"页面被限流,无法验证真实内容: {url}"
# 主程序 get_target_element_list 依赖的结构
cards = result.find_all("div", {"class": "card-body"})
assert len(cards) > 0, "真实页面应包含 card-body 元素"
tags = result.find_all("div", {"class": "video-list-item-tag-wrapper"})
assert len(tags) > 0, "真实页面应包含 video-list-item-tag-wrapper 元素"
logger.info("真实页面包含 {} 个卡片、{} 个标签", len(cards), len(tags))
def test_get_page_all_pages_used_by_main(self):
"""遍历主程序 main() 使用的全部 5 个页面 URL"""
for page_no in range(1, 6):
url = MAIN_PAGE_URL_TEMPLATE.format(page=page_no)
result, _ = get_page_with_retry(url)
# get_page 的真实契约:请求成功并解析出页面(失败时返回 None)
assert result is not None, f"页面获取失败: {url}"
assert isinstance(result, BeautifulSoup)
# 正常页面应包含卡片;限流页面(429)是真实的服务器行为,予以容忍
cards = result.find_all("div", {"class": "card-body"})
assert len(cards) > 0 or is_rate_limited(result), \
f"页面 {url} 既无卡片内容也不是限流响应"
logger.info("页面 {} 获取成功,包含 {} 个卡片", url, len(cards))
def test_get_page_failed_request_returns_none(self):
"""对不可达的 URL 调用真实函数,应走真实网络错误路径并返回 None"""
url = "http://invalid-domain-for-test-12345.invalid"
result = get_page(url)
assert result is None
def run_tests():
"""运行所有测试"""
pytest.main([
__file__,
"-v",
"--tb=short",
"--log-cli-level=INFO"
])
if __name__ == "__main__":
run_tests()
+278
View File
@@ -0,0 +1,278 @@
"""
测试 qBittorrent 下载器
"""
import pytest
import requests
from unittest.mock import Mock, patch, MagicMock
from loguru import logger
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from ariaDownloaderService.QbittorrentDownloader import QbittorrentDownloader
from dataService.dto.SeedInfo import SeedInfo
class TestQbittorrentDownloader:
"""qBittorrent 下载器测试类"""
def setup_method(self):
"""每个测试方法前的设置"""
# 配置 loguru 输出到控制台
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_initialization_without_auth(self):
"""测试不使用认证的初始化"""
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
assert downloader.host == "192.168.123.199"
assert downloader.api_port == 8085
assert downloader.api_base_url == "http://192.168.123.199:8085/api/v2"
assert downloader.username == ""
assert downloader.password == ""
logger.info("不使用认证的初始化测试通过")
def test_initialization_with_auth(self):
"""测试使用认证的初始化"""
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085,
username="admin",
password="GUOxy.5157"
)
assert downloader.host == "192.168.123.199"
assert downloader.api_port == 8085
assert downloader.username == "admin"
assert downloader.password == "GUOxy.5157"
assert downloader.api_base_url == "http://192.168.123.199:8085/api/v2"
logger.info("使用认证的初始化测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_magnet_success(self, mock_post):
"""测试通过 magnet 链接成功添加下载任务"""
# 模拟成功的响应
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = ""
mock_post.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
result = downloader.download_by_magnet(magnet_url)
assert result == "1"
mock_post.assert_called_once()
# 验证请求参数
call_args = mock_post.call_args
assert call_args[0][0] == "http://192.168.123.199:8085/api/v2/torrents/add"
assert call_args[1]['data']['urls'] == magnet_url
logger.info("通过 magnet 链接成功添加下载任务测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_magnet_failure_status(self, mock_post):
"""测试请求返回非200状态码的情况"""
# 模拟失败的响应
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_post.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
result = downloader.download_by_magnet(magnet_url)
assert result == "-1"
logger.info("请求返回非200状态码测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_magnet_exception(self, mock_post):
"""测试请求过程中发生异常的情况"""
# 模拟异常
mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
result = downloader.download_by_magnet(magnet_url)
assert result == "0"
logger.info("请求过程中发生异常测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_seed_success(self, mock_post):
"""测试通过种子信息成功添加下载任务"""
# 模拟成功的响应
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = ""
mock_post.return_value = mock_response
# 创建种子信息对象
seed_info = SeedInfo(name="测试种子", seedUrl="magnet:?xt=urn:btih:1234567890abcdef")
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.download_by_seed(seed_info)
assert result == "1"
mock_post.assert_called_once()
# 验证传递的 URL 是种子信息中的 seedUrl
call_args = mock_post.call_args
assert call_args[1]['data']['urls'] == "magnet:?xt=urn:btih:1234567890abcdef"
logger.info("通过种子信息成功添加下载任务测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_seed_empty_url(self, mock_post):
"""测试种子信息中没有 URL 的情况"""
# 创建种子信息对象,seedUrl 为空
seed_info = SeedInfo(name="测试种子", seedUrl=None)
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.download_by_seed(seed_info)
assert result == "0"
logger.info("种子信息中没有 URL 测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.post')
def test_download_by_magnet_with_options(self, mock_post):
"""测试带保存路径和分类的下载"""
# 模拟成功的响应
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = ""
mock_post.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
result = downloader.download_by_magnet(
magnet_url,
save_path="/downloads/test",
category="movies",
tags="test"
)
assert result == "1"
# 验证请求参数包含所有选项
call_args = mock_post.call_args
data = call_args[1]['data']
assert data['urls'] == magnet_url
assert data['savepath'] == "/downloads/test"
assert data['category'] == "movies"
assert data['tags'] == "test"
logger.info("带保存路径和分类的下载测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.get')
def test_test_connection_success(self, mock_get):
"""测试连接测试成功"""
# 模拟成功的响应
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = "v4.6.0"
mock_get.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.test_connection()
assert result is True
mock_get.assert_called_once_with("http://192.168.123.199:8085/api/v2/app/version")
logger.info("连接测试成功测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.get')
def test_test_connection_failure(self, mock_get):
"""测试连接测试失败"""
# 模拟失败的响应
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.test_connection()
assert result is False
logger.info("连接测试失败测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.get')
def test_get_downloads_success(self, mock_get):
"""测试获取下载列表成功"""
# 模拟成功的响应
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"name": "测试任务1", "progress": 50},
{"name": "测试任务2", "progress": 100}
]
mock_get.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.get_downloads()
assert len(result) == 2
assert result[0]["name"] == "测试任务1"
mock_get.assert_called_once_with("http://192.168.123.199:8085/api/v2/torrents/info")
logger.info("获取下载列表成功测试通过")
@patch('ariaDownloaderService.QbittorrentDownloader.session.get')
def test_get_downloads_failure(self, mock_get):
"""测试获取下载列表失败"""
# 模拟失败的响应
mock_response = Mock()
mock_response.status_code = 500
mock_get.return_value = mock_response
downloader = QbittorrentDownloader(
host="192.168.123.199",
api_port=8085
)
result = downloader.get_downloads()
assert result == []
logger.info("获取下载列表失败测试通过")
def run_tests():
"""运行所有测试"""
pytest.main([
__file__,
"-v",
"--tb=short",
"--log-cli-level=INFO"
])
if __name__ == "__main__":
run_tests()
+170
View File
@@ -0,0 +1,170 @@
"""
测试 seed_is_exist 功能(真实数据库)
测试说明:
- 调用真实的 seed_is_exist 函数和真实 MySQL 数据库(192.168.123.199
- 首次运行自动创建 LookingWeb 库和 seeds_info 表(幂等,可重复运行)
- 测试插入的数据在测后自动清理,不污染数据库
"""
import sys
import os
import uuid
import pymysql
import pytest
from loguru import logger
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from dataService.MySqlService import DatabaseConfig, MySQLDatabase, seed_is_exist, save_seed
from dataService.dto.SeedInfo import SeedInfo
CREATE_DATABASE_SQL = (
"CREATE DATABASE IF NOT EXISTS `LookingWeb` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS `seeds_info` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) DEFAULT NULL,
seed_url VARCHAR(512) NOT NULL,
have_download VARCHAR(16) DEFAULT NULL,
create_time DATETIME DEFAULT NULL,
update_time DATETIME DEFAULT NULL,
url VARCHAR(512) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_seed_url (seed_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
# 本模块测试插入过的种子 URL,测后统一清理
_inserted_urls = []
def ensure_schema():
"""幂等创建 LookingWeb 库和 seeds_info 表(真实数据库)"""
# 第一步:不带库名连接,先创建数据库
conn = pymysql.connect(
host=DatabaseConfig.HOST, port=DatabaseConfig.PORT,
user=DatabaseConfig.USER, password=DatabaseConfig.PASSWORD,
charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
try:
with conn.cursor() as cur:
cur.execute(CREATE_DATABASE_SQL)
conn.commit()
logger.info("数据库就绪: {}(已确认存在)", DatabaseConfig.DB)
finally:
conn.close()
# 第二步:连接 LookingWeb 库,创建 seeds_info 表
conn = pymysql.connect(
host=DatabaseConfig.HOST, port=DatabaseConfig.PORT,
user=DatabaseConfig.USER, password=DatabaseConfig.PASSWORD,
db=DatabaseConfig.DB, charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
try:
with conn.cursor() as cur:
cur.execute(CREATE_TABLE_SQL)
conn.commit()
logger.info("数据表就绪: seeds_info")
finally:
conn.close()
def make_test_seed():
"""创建带唯一 seed_url 的真实 SeedInfo 对象,并登记以便测后清理"""
url = f"magnet:?xt=urn:btih:{uuid.uuid4().hex}"
_inserted_urls.append(url)
return SeedInfo(name="测试种子", seedUrl=url, haveDownload="1", url=url)
@pytest.fixture(scope="module", autouse=True)
def db_schema():
"""模块级:确保真实数据库和表存在"""
ensure_schema()
yield
@pytest.fixture(autouse=True)
def cleanup_inserted_seeds():
"""每个测试后:清理本模块插入的种子行,保证测试可重复运行"""
yield
if not _inserted_urls:
return
with MySQLDatabase.create_connection() as db:
for seed_url in _inserted_urls:
db.execute("DELETE FROM seeds_info WHERE seed_url = %s", (seed_url,))
_inserted_urls.clear()
class TestSeedIsExist:
"""使用真实数据库测试 seed_is_exist"""
def setup_method(self):
logger.remove()
logger.add(sys.stderr, level="DEBUG")
def test_seed_not_exists_returns_false(self):
"""数据库中不存在的种子 URL 应返回 False"""
seed = make_test_seed()
logger.info("测试种子不存在场景: {}", seed.seedUrl)
result = seed_is_exist(seed)
assert result is False
def test_seed_exists_returns_true(self):
"""先通过真实的 save_seed 插入种子,seed_is_exist 应返回 True"""
seed = make_test_seed()
assert save_seed(seed) is True, "前置条件:save_seed 应成功插入种子"
logger.info("测试种子已插入: {}", seed.seedUrl)
result = seed_is_exist(seed)
assert result is True
def test_multiple_seeds_all_exist(self):
"""多个已存在种子都应被识别为存在"""
seeds = [make_test_seed() for _ in range(3)]
for seed in seeds:
assert save_seed(seed) is True
for seed in seeds:
assert seed_is_exist(seed) is True, f"种子应已存在: {seed.seedUrl}"
def test_seed_exists_after_delete_returns_false(self):
"""删除后的种子再查询应返回 False(完整的真实生命周期)"""
from dataService.MySqlService import delete_seed
seed = make_test_seed()
assert save_seed(seed) is True
assert seed_is_exist(seed) is True
assert delete_seed(seed) is True, "前置条件:delete_seed 应成功删除种子"
assert seed_is_exist(seed) is False
def test_db_unreachable_returns_false(self, monkeypatch):
"""数据库不可达时(真实错误路径)应返回 False,而不是抛异常"""
# 指向同主机一个已关闭的端口,模拟数据库不可达
monkeypatch.setattr(DatabaseConfig, "PORT", 3307)
seed = make_test_seed()
result = seed_is_exist(seed)
assert result is False
def run_tests():
"""运行所有测试"""
pytest.main([
__file__,
"-v",
"--tb=short",
"--log-cli-level=INFO"
])
if __name__ == "__main__":
run_tests()