235 lines
7.8 KiB
Python
235 lines
7.8 KiB
Python
"""
|
|
单元测试集合 (tests/test_all.py)
|
|
涵盖:
|
|
1. 纯函数与排序、映射规则测试 (表驱动)
|
|
2. 学段匹配逻辑与空值安全性测试 (is_match_xd, nj None-safety)
|
|
3. 本地教材目录检索与结构化分类测试 (PepCatalog filter & structure)
|
|
4. AES-128-CBC 加解密链路 round-trip 与正则匹配测试
|
|
5. PepDownloader skip_if_exists 离线快路径测试
|
|
6. WebUI FastAPI API 接口测试
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import base64
|
|
import binascii
|
|
import tempfile
|
|
import pytest
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.primitives import padding
|
|
from fastapi.testclient import TestClient
|
|
|
|
# 将项目根目录加入 sys.path
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if BASE_DIR not in sys.path:
|
|
sys.path.insert(0, BASE_DIR)
|
|
|
|
from pep_core import (
|
|
normalize_xd,
|
|
sort_xd_key,
|
|
sort_xk_key,
|
|
sort_nj_key,
|
|
map_book_xd,
|
|
PepCatalog,
|
|
PepDownloader,
|
|
XD_ORDER,
|
|
XK_ORDER_PREFIX,
|
|
NJ_ORDER,
|
|
)
|
|
from download_all import sanitize_filename, is_match_xd
|
|
from webui import app
|
|
|
|
|
|
# ==========================================
|
|
# 1. 纯函数测试
|
|
# ==========================================
|
|
|
|
@pytest.mark.parametrize("raw, expected", [
|
|
("小学", "小学(六三学制)"),
|
|
("初中", "初中(六三学制)"),
|
|
("小学(五·四学制)", "小学(五四学制)"),
|
|
("小学(五四学制)", "小学(五四学制)"),
|
|
("初中(五·四学制)", "初中(五·四学制)"),
|
|
("初中(五四学制)", "初中(五·四学制)"),
|
|
("高中", "高中"),
|
|
("", "其他"),
|
|
(None, "其他"),
|
|
])
|
|
def test_normalize_xd(raw, expected):
|
|
assert normalize_xd(raw) == expected
|
|
|
|
|
|
def test_sort_keys():
|
|
# 学段排序测试
|
|
sorted_xds = sorted(XD_ORDER, key=sort_xd_key)
|
|
assert sorted_xds == XD_ORDER
|
|
# 未知学段排在后面
|
|
assert sort_xd_key("未知学段")[0] == 1
|
|
|
|
# 学科前缀优先排序
|
|
sorted_xks = sorted(XK_ORDER_PREFIX, key=sort_xk_key)
|
|
assert sorted_xks == XK_ORDER_PREFIX
|
|
assert sort_xk_key("未知学科")[0] == 1
|
|
|
|
# 年级排序
|
|
sorted_njs = sorted(NJ_ORDER, key=sort_nj_key)
|
|
assert sorted_njs == NJ_ORDER
|
|
|
|
|
|
@pytest.mark.parametrize("meta, expected", [
|
|
({"xd": "小学", "xdtype": "盲文"}, "盲校(盲文版)"),
|
|
({"xd": "初中", "xdtype": "低视力"}, "盲校(低视力版)"),
|
|
({"xd": "小学", "xdtype": "聋校"}, "聋校"),
|
|
({"xd": "小学", "xdtype": "培智"}, "培智学校"),
|
|
({"xd": "初中", "xdtype": "六三"}, "初中(六三学制)"),
|
|
({"xd": "小学", "xdtype": "六三"}, "小学(六三学制)"),
|
|
({"xd": "初中", "xdtype": "五四"}, "初中(五·四学制)"),
|
|
({"xd": "小学", "xdtype": "五四"}, "小学(五四学制)"),
|
|
({"xd": "高中", "xdtype": ""}, "高中"),
|
|
])
|
|
def test_map_book_xd(meta, expected):
|
|
assert map_book_xd(meta) == expected
|
|
|
|
|
|
def test_sanitize_filename():
|
|
assert sanitize_filename('test/book:name*1') == "test_book_name_1"
|
|
assert sanitize_filename('test/book:name*1?"<>|') == "test_book_name_1_____"
|
|
|
|
|
|
# ==========================================
|
|
# 2. 学段匹配与空值安全
|
|
# ==========================================
|
|
|
|
@pytest.mark.parametrize("b_xd, b_xdtype, targets, expected", [
|
|
("小学(六三学制)", "六三学制", ["义务教育(六三学制)"], True),
|
|
("初中(五·四学制)", "五四学制", ["义务教育(五四学制)"], True),
|
|
("高中", "普通高中", ["高中"], True),
|
|
("小学(六三学制)", "六三学制", ["高中"], False),
|
|
("小学", "六三", [], True), # 无过滤条件全部匹配
|
|
])
|
|
def test_is_match_xd(b_xd, b_xdtype, targets, expected):
|
|
assert is_match_xd(b_xd, b_xdtype, targets) == expected
|
|
|
|
|
|
def test_nj_null_safety():
|
|
"""测试 nj 为 None 时的安全性"""
|
|
b = {"id": "123", "title": "测试教材", "xd": "小学", "nj": None}
|
|
nj = (b.get("nj") or "通用").strip() or "通用"
|
|
assert nj == "通用"
|
|
|
|
|
|
# ==========================================
|
|
# 3. 目录检索与结构化分类 (读取本地 pep_catalog.json)
|
|
# ==========================================
|
|
|
|
def test_pep_catalog_filter_and_structure():
|
|
books = PepCatalog.fetch_and_decrypt_all()
|
|
assert len(books) > 0, "应成功从本地缓存读取教材数据"
|
|
|
|
# 测试条件过滤
|
|
filtered = PepCatalog.filter_books(xd="高中", xk="数学")
|
|
assert len(filtered) > 0
|
|
for b in filtered:
|
|
assert b["xd"] == "高中"
|
|
assert b["xk"] == "数学"
|
|
|
|
# 测试关键词搜索
|
|
kw_filtered = PepCatalog.filter_books(keyword="语文")
|
|
assert len(kw_filtered) > 0
|
|
|
|
# 测试结构体解析
|
|
structure = PepCatalog.get_structure()
|
|
assert "高中" in structure
|
|
assert "数学" in structure["高中"]["subjects"]
|
|
|
|
|
|
# ==========================================
|
|
# 4. AES-128-CBC 加解密链路 round-trip 测试
|
|
# ==========================================
|
|
|
|
def test_aes_round_trip():
|
|
key = PepCatalog.KEY
|
|
iv = PepCatalog.IV
|
|
|
|
sample_data = {"data": [{"id": "999999", "title": "单元测试教材"}]}
|
|
plain_bytes = json.dumps(sample_data).encode("utf-8")
|
|
|
|
# 模拟加密与 PKCS7 padding
|
|
padder = padding.PKCS7(128).padder()
|
|
padded_data = padder.update(plain_bytes) + padder.finalize()
|
|
|
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
encryptor = cipher.encryptor()
|
|
cipher_bytes = encryptor.update(padded_data) + encryptor.finalize()
|
|
hex_str = binascii.hexlify(cipher_bytes).decode("ascii").upper()
|
|
|
|
# 模拟从前端 JS 中匹配 hex_str
|
|
mock_js = f'var o, c = "{hex_str}";'.encode("ascii")
|
|
import re
|
|
m = re.search(rb'c\s*=\s*"([A-F0-9]+)"', mock_js)
|
|
assert m is not None
|
|
|
|
extracted_hex = m.group(1).decode("ascii")
|
|
extracted_cipher = binascii.unhexlify(extracted_hex)
|
|
|
|
# 执行解密
|
|
decryptor = cipher.decryptor()
|
|
decrypted_padded = decryptor.update(extracted_cipher) + decryptor.finalize()
|
|
unpadder = padding.PKCS7(128).unpadder()
|
|
decrypted_plain = unpadder.update(decrypted_padded) + unpadder.finalize()
|
|
|
|
res_json = json.loads(decrypted_plain.decode("utf-8"))
|
|
assert res_json == sample_data
|
|
|
|
|
|
# ==========================================
|
|
# 5. 下载器 skip_if_exists 离线快路径测试
|
|
# ==========================================
|
|
|
|
def test_downloader_skip_if_exists():
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
downloader = PepDownloader(headless=True, output_dir=tmp_dir)
|
|
fake_pdf = os.path.join(tmp_dir, "测试教材.pdf")
|
|
# 写入大于 50KB 的假文件模拟已存在 PDF
|
|
with open(fake_pdf, "wb") as f:
|
|
f.write(b"%PDF-1.4 " + b"0" * 60000)
|
|
|
|
# 调用 download_book,由于文件已存在且 > 50KB,应直接秒退并返回路径,无需启动 Playwright 浏览器
|
|
result_path = downloader.download_book(
|
|
book_id="1384001301261",
|
|
custom_title="测试教材",
|
|
skip_if_exists=True
|
|
)
|
|
assert result_path == fake_pdf
|
|
|
|
|
|
# ==========================================
|
|
# 6. WebUI API 接口测试
|
|
# ==========================================
|
|
|
|
client = TestClient(app)
|
|
|
|
def test_webui_api_structure():
|
|
response = client.get("/api/structure")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, dict)
|
|
assert len(data) > 0
|
|
|
|
|
|
def test_webui_api_books():
|
|
response = client.post("/api/books", json={"xd": "高中", "xk": "数学", "nj": "全部", "keyword": ""})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "books" in data
|
|
assert data["total"] > 0
|
|
|
|
|
|
def test_webui_api_status():
|
|
response = client.get("/api/status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "is_running" in data
|
|
assert "queue_len" in data
|