签入版本

This commit is contained in:
2026-09-12 19:09:28 +08:00
commit 4eae62bc17
803 changed files with 105034 additions and 0 deletions
@@ -0,0 +1,392 @@
"""
仓储基类单元测试(P0 基建)
=============================================================
使用 sqlite 内存库(conftest.app 夹具)验证 BaseRepository 通用能力:
- find_page/find_list 的 like 通配符转义
- 多字段排序(order_by + order_desc 单次合并,修复覆盖 bug)
- 软删除与恢复(batch_delete 后 get_by_id 不可见)
- update_by_id 自动补齐 update_time/update_user(显式值优先)
- first_by 等值单条查询 / exists / create
- calc_pages / build_page_data 分页结构
"""
from datetime import datetime
from src.core.database import db
from src.common.constants.common import DEFAULT_PASSWORD
from src.core.password import encrypt_password, generate_salt
from src.modules.system.position.repository import PositionRepository
from src.modules.system.user.repository import UserRepository
from src.modules.system.role.repository import RoleRepository
from src.modules.dictionary.dict.repository import DictRepository
from src.modules.dictionary.dict_item.repository import DictItemRepository
from src.modules.association.user_role.repository import UserRoleRepository
from src.modules.operation_log.repository import OperationLogRepository
from src.common.utils.model_utils import calc_pages, build_page_data
def _make_position(app, name, sort=0, status=1, create_time=None):
"""在测试库中构造一条岗位数据并提交"""
data = {'name': name, 'sort': sort, 'status': status}
if create_time is not None:
data['create_time'] = create_time
with app.app_context():
position = PositionRepository.create(**data)
db.session.commit()
return position.id
def _names(app, items):
return [p.name for p in items]
class TestFindPageLike:
"""like 模糊查询通配符转义"""
def test_special_chars_escaped(self, app):
_make_position(app, '100%_x')
_make_position(app, '100_x')
_make_position(app, '经理100%')
_make_position(app, '普通')
with app.app_context():
items = PositionRepository.find_list(like_filters={'name': '100%_x'})
# 通配符被转义,仅匹配字面量 "100%_x"
assert _names(app, items) == ['100%_x']
class TestEmptyFilterValues:
"""空筛选值处理:None 或 '' 不参与筛选(修复前端传 '' 导致查不出数据)"""
def test_empty_string_equality_ignored(self, app):
_make_position(app, '甲')
_make_position(app, '乙')
with app.app_context():
items = PositionRepository.find_list(filters={'name': ''})
assert len(items) == 2
def test_none_equality_ignored(self, app):
_make_position(app, '甲')
_make_position(app, '乙')
with app.app_context():
items = PositionRepository.find_list(filters={'name': None})
assert len(items) == 2
def test_zero_equality_still_filters(self, app):
_make_position(app, '启用', status=1)
_make_position(app, '禁用', status=0)
with app.app_context():
items = PositionRepository.find_list(filters={'status': 0})
assert len(items) == 1
def test_empty_string_like_ignored(self, app):
_make_position(app, '甲')
_make_position(app, '乙')
with app.app_context():
items = PositionRepository.find_list(like_filters={'name': ''})
assert len(items) == 2
class TestMultiFieldOrder:
"""多字段排序:order_by 与 order_desc 单次合并,不再相互覆盖"""
def test_sort_then_id_desc(self, app):
_make_position(app, 'A', sort=2)
_make_position(app, 'B', sort=1)
_make_position(app, 'C', sort=1)
_make_position(app, 'D', sort=2)
with app.app_context():
items = PositionRepository.find_list(order_by=['sort'], order_desc=['id'])
# sort 升序(1 组在前),组内 id 降序
assert _names(app, items) == ['C', 'B', 'D', 'A']
class TestSoftDelete:
"""软删除与恢复"""
def test_batch_delete_hides_records(self, app):
id1 = _make_position(app, '甲')
id2 = _make_position(app, '乙')
id3 = _make_position(app, '丙')
with app.app_context():
count = PositionRepository.batch_delete([id1, id2])
assert count == 2
assert PositionRepository.get_by_id(id1) is None
assert PositionRepository.get_by_id(id2) is None
assert PositionRepository.get_by_id(id3) is not None
assert len(PositionRepository.get_all()) == 1
def test_batch_restore_recovers(self, app):
id1 = _make_position(app, '甲')
id2 = _make_position(app, '乙')
with app.app_context():
PositionRepository.batch_delete([id1])
restored = PositionRepository.batch_restore([id1])
assert restored == 1
assert PositionRepository.get_by_id(id1) is not None
assert PositionRepository.get_by_id(id2) is not None
class TestUpdate:
"""更新能力"""
def test_update_by_id_stamps_and_keeps_explicit(self, app):
pid = _make_position(app, '原名称')
with app.app_context():
affected = PositionRepository.update_by_id(pid, {'name': '新名称'})
assert affected == 1
db.session.expire_all()
position = PositionRepository.get_by_id(pid)
assert position.name == '新名称'
assert position.update_time is not None
def test_update_by_id_explicit_values_preferred(self, app):
pid = _make_position(app, '原名称')
with app.app_context():
PositionRepository.update_by_id(pid, {'name': '显式更新', 'update_user': 'admin'})
db.session.expire_all()
position = PositionRepository.get_by_id(pid)
assert position.name == '显式更新'
assert position.update_user == 'admin'
def test_update_status(self, app):
pid = _make_position(app, '岗位')
with app.app_context():
affected = PositionRepository.update_status(pid, 0)
assert affected == 1
db.session.expire_all()
assert PositionRepository.get_by_id(pid).status == 0
class TestFirstByAndExists:
"""等值单条查询与存在性检查"""
def test_first_by(self, app):
_make_position(app, '唯一岗位')
with app.app_context():
found = PositionRepository.first_by({'name': '唯一岗位'})
assert found is not None
assert PositionRepository.first_by({'name': '不存在'}) is None
def test_exists(self, app):
pid = _make_position(app, '存在性检查')
with app.app_context():
assert PositionRepository.exists(pid) is True
assert PositionRepository.exists(99999) is False
class TestExistsBy:
"""唯一性校验 exists_by(等值条件 + 更新时排除自身)"""
def test_hit_and_miss(self, app):
_make_position(app, '唯一岗位')
with app.app_context():
assert PositionRepository.exists_by({'name': '唯一岗位'}) is True
assert PositionRepository.exists_by({'name': '不存在的岗位'}) is False
def test_exclude_id(self, app):
pid = _make_position(app, '同名岗位')
with app.app_context():
# 更新场景:排除自身后不应命中
assert PositionRepository.exists_by({'name': '同名岗位'}, exclude_id=pid) is False
def test_ignores_soft_deleted(self, app):
pid = _make_position(app, '软删岗位')
with app.app_context():
PositionRepository.batch_delete([pid])
assert PositionRepository.exists_by({'name': '软删岗位'}) is False
def test_compound_filters(self, app):
with app.app_context():
dict_obj = DictRepository.create(name='性别', code='gender', sort=0)
db.session.commit()
dict_id = dict_obj.id
DictItemRepository.create(dict_id=dict_id, name='男', value='1', sort=1)
db.session.commit()
assert DictItemRepository.exists_by({'dict_id': dict_id, 'value': '1'}) is True
assert DictItemRepository.exists_by({'dict_id': dict_id, 'value': '2'}) is False
def test_empty_filters_false(self, app):
with app.app_context():
assert PositionRepository.exists_by({}) is False
assert PositionRepository.exists_by({'not_a_column': 1}) is False
class TestCreate:
"""创建能力"""
def test_create_returns_object(self, app):
with app.app_context():
position = PositionRepository.create(name='新建岗位', sort=5)
db.session.commit()
assert position.id is not None
assert PositionRepository.get_by_id(position.id).name == '新建岗位'
def test_get_by_ids(self, app):
id1 = _make_position(app, '一')
id2 = _make_position(app, '二')
with app.app_context():
records = PositionRepository.get_by_ids([id1, id2, 99999])
assert len(records) == 2
def _make_operation_log(app, title, create_time):
"""在测试库中构造一条操作日志并提交(覆盖 create_time)"""
with app.app_context():
log = OperationLogRepository.create(
title=title,
type=1,
source=0,
method='test',
request_method='GET',
url='/api/v1/test',
param='',
result='',
ip='127.0.0.1',
location='',
os='',
browser='',
consume_time=0,
status=0,
error='',
create_time=create_time,
)
db.session.commit()
return log.id
class TestLogTimeRange:
"""操作日志时间范围筛选(page 参数映射)"""
def test_page_time_range_filter(self, app):
_make_operation_log(app, '一月日志', datetime(2026, 1, 15, 10, 0, 0))
_make_operation_log(app, '二月日志', datetime(2026, 2, 15, 10, 0, 0))
_make_operation_log(app, '三月日志', datetime(2026, 3, 15, 10, 0, 0))
with app.app_context():
items, total, pages = OperationLogRepository.page({
'pageNo': 1,
'pageSize': 10,
'startTime': '2026-02-01 00:00:00',
'endTime': '2026-02-28 23:59:59',
})
assert total == 1
assert pages == 1
assert items[0].title == '二月日志'
def test_invalid_date_ignored(self, app):
_make_operation_log(app, '日志', datetime(2026, 1, 15, 10, 0, 0))
with app.app_context():
items, total, _ = OperationLogRepository.page({
'pageNo': 1,
'pageSize': 10,
'startTime': 'not-a-date',
})
assert total == 1
assert items[0].title == '日志'
class TestPageDataUtils:
"""分页工具函数"""
def test_calc_pages(self):
assert calc_pages(0, 10) == 0
assert calc_pages(5, 10) == 1
assert calc_pages(10, 10) == 1
assert calc_pages(11, 10) == 2
def test_build_page_data(self):
data = build_page_data([], 0, 1, 10)
assert data == {'records': [], 'total': 0, 'size': 10, 'current': 1, 'pages': 0}
def test_build_page_data_with_enrich(self, app):
_make_position(app, '富化')
with app.app_context():
items = PositionRepository.find_list()
data = build_page_data(
items, len(items), 1, 10, enrich=lambda records: [
{**r, 'extra': 'added'} for r in records
]
)
assert data['total'] == 1
assert data['pages'] == 1
assert data['records'][0]['extra'] == 'added'
def _make_user(app, username):
"""在测试库中构造一条用户数据并提交(补齐 NOT NULL 字段)"""
salt = generate_salt()
with app.app_context():
user = UserRepository.create(
username=username,
realname=username,
avatar='',
mobile='13800000000',
email='test@test.com',
province_code='',
city_code='',
district_code='',
street_code='',
address='',
password=encrypt_password(DEFAULT_PASSWORD, salt),
salt=salt,
)
db.session.commit()
return user.id
def _make_role(app, name, code):
"""在测试库中构造一条角色数据并提交"""
with app.app_context():
role = RoleRepository.create(name=name, code=code)
db.session.commit()
return role.id
class TestReplaceRoles:
"""用户角色关联 replace_roles 先删后增"""
def test_replace_roles_delete_then_insert(self, app):
user_id = _make_user(app, 'test_user')
role1 = _make_role(app, '管理员', 'admin')
role2 = _make_role(app, '普通用户', 'common')
with app.app_context():
inserted = UserRoleRepository.replace_roles(user_id, [role1, role2])
assert inserted == 2
db.session.commit()
# 二次替换:旧关联全部删除,仅保留 role2
with app.app_context():
inserted = UserRoleRepository.replace_roles(user_id, [role2])
assert inserted == 1
db.session.commit()
role_ids = sorted(r.id for r in UserRoleRepository.get_roles_by_user(user_id))
assert role_ids == [role2]
def test_replace_roles_empty_clears_all(self, app):
user_id = _make_user(app, 'test_user2')
role1 = _make_role(app, '管理员', 'admin')
with app.app_context():
UserRoleRepository.replace_roles(user_id, [role1])
db.session.commit()
assert len(UserRoleRepository.get_roles_by_user(user_id)) == 1
UserRoleRepository.replace_roles(user_id, [])
db.session.commit()
assert UserRoleRepository.get_roles_by_user(user_id) == []
+286
View File
@@ -0,0 +1,286 @@
"""
服务基类单元测试(P1 服务层骨架)
=============================================================
验证 BaseService 通用 CRUD 骨架:
- 模板方法流程:add / update / remove / update_status / get_page / get_detail / get_list
- 表单校验失败 raise BizError
- 存在性校验(update / update_status 对不存在记录 raise BizError)
- 覆写钩子(before_add / after_add / before_delete / after_update)按序触发
- 模块级代理函数形态(保持 router 零改动)
DB 相关用例使用 conftest.app(sqlite 内存库),
表单校验用例使用 conftest.flask_app(最小应用上下文)。
"""
import pytest
from src.common.base_service import BaseService
from src.common.utils.model_utils import build_page_data
from src.core.exceptions import BizError
from src.modules.cms.category.repository import CategoryRepository
from src.modules.cms.category.service import CategoryService
from src.modules.dictionary.dict_item.repository import DictItemRepository
from src.modules.dictionary.dict.repository import DictRepository
from src.modules.dictionary.dict_item.service import DictItemService
from src.modules.system.position.repository import PositionRepository
from src.modules.system.position.schema import PositionForm, PositionStatusForm
def _make_position(app, name, sort=0, status=1):
"""在测试库中构造一条岗位数据并提交"""
from src.core.database import db
with app.app_context():
pid = PositionRepository.create(name=name, sort=sort, status=status).id
db.session.commit()
return pid
class DummyService(BaseService):
"""覆写全部钩子用于断言触发顺序的测试子类"""
repository = PositionRepository
form = PositionForm
status_form = PositionStatusForm
model_name = "岗位"
events = []
@classmethod
def before_add(cls, form):
cls.events.append("before_add")
@classmethod
def after_add(cls, obj):
cls.events.append("after_add")
@classmethod
def before_update(cls, form, obj):
cls.events.append("before_update")
@classmethod
def after_update(cls, obj):
cls.events.append("after_update")
@classmethod
def before_delete(cls, ids):
cls.events.append("before_delete")
@classmethod
def after_delete(cls, count):
cls.events.append("after_delete")
@pytest.fixture(autouse=True)
def _reset_events():
DummyService.events = []
yield
class TestGetPage:
"""分页查询"""
def test_shape_and_records(self, app):
_make_position(app, "岗位甲")
with app.app_context():
data = DummyService.get_page({"pageNo": 1, "pageSize": 10, "name": "岗位"})
assert data["total"] == 1
assert data["size"] == 10
assert data["current"] == 1
assert data["records"][0]["name"] == "岗位甲"
def test_empty_page(self, app):
with app.app_context():
data = DummyService.get_page({"pageNo": 1, "pageSize": 10})
assert data == {"records": [], "total": 0, "size": 10, "current": 1, "pages": 0}
class TestGetDetail:
"""详情查询"""
def test_detail_returns_dict(self, app):
pid = _make_position(app, "详情岗位")
with app.app_context():
detail = DummyService.get_detail(pid)
assert detail is not None
assert detail["id"] == pid
assert detail["name"] == "详情岗位"
def test_detail_missing_returns_none(self, app):
with app.app_context():
assert DummyService.get_detail(99999) is None
class TestGetList:
"""无分页列表"""
def test_list_returns_dicts(self, app):
_make_position(app, "列表一")
_make_position(app, "列表二")
with app.app_context():
items = DummyService.get_list()
assert [it["name"] for it in items] == ["列表一", "列表二"]
class TestAdd:
"""新增"""
def test_form_invalid_raises(self, flask_app):
with flask_app.app_context():
with pytest.raises(BizError, match="岗位名称不能为空"):
DummyService.add({})
def test_add_creates_and_hooks_run(self, app):
from src.core.database import db
with app.app_context():
DummyService.add({"name": "新增岗位", "status": 1, "sort": 3})
db.session.commit()
position = PositionRepository.first_by({"name": "新增岗位"})
assert position is not None
assert position.status == 1
assert DummyService.events == ["before_add", "after_add"]
class TestUpdate:
"""更新"""
def test_nonexistent_raises(self, app):
# 表单唯一性校验与存在性校验均查库,需 sqlite 上下文
with app.app_context():
with pytest.raises(BizError, match="岗位不存在"):
DummyService.update({"id": 99999, "name": "不存在", "status": 1})
def test_update_changes_and_hooks_run(self, app):
from src.core.database import db
pid = _make_position(app, "原名称")
with app.app_context():
DummyService.update({"id": pid, "name": "新名称", "status": 1, "sort": 1})
db.session.commit()
db.session.expire_all()
position = PositionRepository.get_by_id(pid)
assert position.name == "新名称"
assert DummyService.events == ["before_update", "after_update"]
class TestRemove:
"""批量删除"""
def test_invalid_ids_raises(self):
with pytest.raises(BizError, match="无效的记录ID"):
DummyService.remove("abc")
def test_remove_calls_repo_and_hooks(self, app, monkeypatch):
pid = _make_position(app, "待删除")
captured = {}
def fake_batch_delete(cls, ids):
captured["ids"] = ids
return len(ids)
monkeypatch.setattr(PositionRepository, "batch_delete", classmethod(fake_batch_delete))
with app.app_context():
count = DummyService.remove(str(pid))
assert count == 1
assert captured["ids"] == [pid]
assert DummyService.events == ["before_delete", "after_delete"]
class TestUpdateStatus:
"""状态设置"""
def test_nonexistent_raises(self, app):
# 存在性校验查库,需 sqlite 上下文
with app.app_context():
with pytest.raises(BizError, match="岗位不存在"):
DummyService.update_status({"id": 99999, "status": 2})
def test_status_updated(self, app):
pid = _make_position(app, "状态岗位", status=1)
from src.core.database import db
with app.app_context():
DummyService.update_status({"id": pid, "status": 2})
db.session.commit()
db.session.expire_all()
assert PositionRepository.get_by_id(pid).status == 2
class TestProxyShape:
"""模块级代理函数形态:BaseService 方法可直接绑定为模块函数供 router 调用"""
def test_method_binds_to_module_level(self):
assert callable(DummyService.get_page)
assert callable(DummyService.add)
assert callable(DummyService.remove)
# 绑定后调用签名与现有 service 模块级函数一致
assert build_page_data([], 0, 1, 10)["pages"] == 0
class TestBeforeAddExtraFields:
"""before_add 校验父记录存在性"""
def test_dict_item_add_validates_dict_exists(self, app):
from src.core.database import db
with app.app_context():
dict_obj = DictRepository.create(name="性别", code="gender", sort=0)
db.session.commit()
dict_id = dict_obj.id
with app.app_context():
DictItemService.add({"dictId": dict_id, "name": "男", "value": "1", "sort": 1})
db.session.commit()
item = DictItemRepository.first_by({"dict_id": dict_id})
assert item is not None
assert item.name == "男"
def test_dict_item_add_missing_dict_raises(self, app):
with app.app_context():
with pytest.raises(BizError, match="字典不存在"):
DictItemService.add({"dictId": 99999, "name": "男", "value": "1"})
class TestMigratedModuleHooks:
"""真实迁移模块的钩子端到端接线(before_delete 子级校验)"""
def _make_category(self, app, name, parent_id=0, sort=0, type_=1):
from src.core.database import db
with app.app_context():
cid = CategoryRepository.create(
category_name=name, category_code=f"code_{name}",
parent_id=parent_id, sort=sort, type=type_,
).id
db.session.commit()
return cid
def test_category_delete_with_children_rejected(self, app):
parent_id = self._make_category(app, "总公司")
self._make_category(app, "技术部", parent_id=parent_id)
with app.app_context():
with pytest.raises(BizError, match="存在子分类"):
CategoryService.remove(str(parent_id))
def test_category_delete_leaf_succeeds(self, app):
parent_id = self._make_category(app, "总公司")
child_id = self._make_category(app, "技术部", parent_id=parent_id)
with app.app_context():
count = CategoryService.remove(str(child_id))
assert count == 1
def test_category_add_defaults_type(self, app):
"""前端不传 type → 模型 default=1 兜底(原实现 NOT NULL 必崩)"""
from src.core.database import db
with app.app_context():
CategoryService.add({"categoryName": "默认类型分类", "categoryCode": "default_type"})
db.session.commit()
cat = CategoryRepository.first_by({"category_name": "默认类型分类"})
assert cat is not None
assert cat.type == 1
@@ -0,0 +1,38 @@
"""
命名转换工具单元测试
=============================================================
- convert_camel_case / convert_snake_case:单字符串转换
- convert_camel_to_snake / convert_snake_to_camel:字典键递归转换
"""
from src.common.utils.case_converter import (
convert_camel_case,
convert_snake_case,
convert_camel_to_snake,
convert_snake_to_camel,
)
class TestSingleString:
def test_snake_to_camel(self):
assert convert_camel_case("user_id") == "userId"
def test_camel_to_snake(self):
assert convert_snake_case("userId") == "user_id"
class TestDictKeys:
def test_camel_to_snake_dict(self):
data = {"userId": 1, "userInfo": {"realName": "张三"}}
assert convert_camel_to_snake(data) == {"user_id": 1, "user_info": {"real_name": "张三"}}
def test_snake_to_camel_dict(self):
data = {"user_id": 1, "user_info": {"real_name": "张三"}}
assert convert_snake_to_camel(data) == {"userId": 1, "userInfo": {"realName": "张三"}}
def test_roundtrip(self):
source = {"user_id": 1, "role_ids": [1, 2]}
camel = convert_snake_to_camel(source)
assert camel == {"userId": 1, "roleIds": [1, 2]}
assert convert_camel_to_snake(camel) == source
+201
View File
@@ -0,0 +1,201 @@
"""
数据库 URL 构建单元测试(D1 多库支持)
=============================================================
验证 _build_database_url() 对 mysql / postgresql / mssql / sqlite / oracle
五类驱动的 URL 构建正确,且 sqlite 无 host/port/账号分支。
"""
import config.database as db_mod
from src.common.constants.common import DEFAULT_PASSWORD
def _url(monkeypatch, driver, **overrides):
defaults = {
'DB_DRIVER': driver,
'DB_HOST': '127.0.0.1',
'DB_PORT': 1,
'DB_DATABASE': 'appdb',
'DB_USERNAME': 'user',
'DB_PASSWORD': 'pass',
'DB_SSL': False,
}
defaults.update(overrides)
for key, value in defaults.items():
monkeypatch.setattr(db_mod, key, value)
return db_mod._build_database_url()
class TestBuildDatabaseUrl:
def test_mysql_default(self, monkeypatch):
assert _url(monkeypatch, 'mysql') == 'mysql+pymysql://user:pass@127.0.0.1:1/appdb?charset=utf8mb4'
def test_postgresql(self, monkeypatch):
assert _url(monkeypatch, 'postgresql') == 'postgresql+psycopg2://user:pass@127.0.0.1:1/appdb'
def test_pgsql_alias(self, monkeypatch):
assert _url(monkeypatch, 'pgsql') == 'postgresql+psycopg2://user:pass@127.0.0.1:1/appdb'
def test_mssql(self, monkeypatch):
assert _url(monkeypatch, 'mssql') == 'mssql+pymssql://user:pass@127.0.0.1:1/appdb'
def test_sqlite_file_path(self, monkeypatch):
# Windows 反斜杠路径应规范化为正斜杠,且无 host/账号
assert _url(monkeypatch, 'sqlite', DB_DATABASE=r'D:\data\app.db') == 'sqlite:///D:/data/app.db'
def test_sqlite_memory(self, monkeypatch):
assert _url(monkeypatch, 'sqlite', DB_DATABASE=':memory:') == 'sqlite://'
def test_oracle_service_name(self, monkeypatch):
# Oracle 无"数据库名",DB_DATABASE 即 SERVICE_NAME,走 ?service_name= 查询参数
assert _url(monkeypatch, 'oracle') == 'oracle+oracledb://user:pass@127.0.0.1:1/?service_name=appdb'
def test_unknown_driver_falls_back_mysql(self, monkeypatch):
assert _url(monkeypatch, 'db2') == 'mysql+pymysql://user:pass@127.0.0.1:1/appdb?charset=utf8mb4'
def test_driver_default_ports(self):
# DB_PORT 未显式配置时按驱动回退(_DEFAULT_PORT 表)
assert db_mod._DEFAULT_PORT['oracle'] == 1521
assert db_mod._DEFAULT_PORT['mysql'] == 3306
assert db_mod._DEFAULT_PORT['postgresql'] == 5432
assert db_mod._DEFAULT_PORT['pgsql'] == 5432
assert db_mod._DEFAULT_PORT['mssql'] == 1433
assert db_mod._DEFAULT_PORT['sqlite'] == 0
class TestSeedData:
"""种子数据(D3):幂等写入 admin/角色/菜单"""
def test_seed_creates_admin_role_menu(self, app):
from scripts.seed_data import seed_all
from src.modules.system.user.repository import UserRepository
from src.modules.system.role.repository import RoleRepository
from src.modules.system.menu.repository import MenuRepository
with app.app_context():
seed_all()
admin = UserRepository.first_by({'username': 'admin'})
assert admin is not None
assert admin.password != DEFAULT_PASSWORD # 已加密
assert admin.salt
role = RoleRepository.first_by({'code': 'admin'})
assert role is not None
assert MenuRepository.first_by({'name': '系统管理', 'parent_id': 0}) is not None
def test_seed_is_idempotent(self, app):
from scripts.seed_data import seed_all
from src.modules.system.user.repository import UserRepository
with app.app_context():
seed_all()
seed_all()
admins = UserRepository.find_list(filters={'username': 'admin'})
assert len(admins) == 1
class TestNormalizeDriver:
"""驱动别名归一化(_normalize_driver,与 migrate_db.py 共用)"""
def test_canonical(self):
from config.database import _normalize_driver
assert _normalize_driver('mysql') == 'mysql'
assert _normalize_driver('postgresql') == 'postgresql'
assert _normalize_driver('mssql') == 'mssql'
assert _normalize_driver('sqlite') == 'sqlite'
assert _normalize_driver('oracle') == 'oracle'
def test_aliases(self):
from config.database import _normalize_driver
assert _normalize_driver('postgres') == 'postgresql'
assert _normalize_driver('pgsql') == 'postgresql'
assert _normalize_driver('sqlserver') == 'mssql'
assert _normalize_driver('sqlite3') == 'sqlite'
def test_upper_case(self):
from config.database import _normalize_driver
assert _normalize_driver('PostgreSQL') == 'postgresql'
class TestSqliteUrl:
"""SQLite 连接 URL 构建(_sqlite_url,与 migrate_db.py 共用)"""
def test_memory(self):
from config.database import _sqlite_url
assert _sqlite_url(':memory:') == 'sqlite://'
def test_windows_path(self):
from config.database import _sqlite_url
assert _sqlite_url(r'D:\data\app.db') == 'sqlite:///D:/data/app.db'
class TestMigrateDbUrl:
"""跨库迁移脚本 migrate_db.py 的 URL 构建(显式参数版,M1)"""
def test_make_url_mysql(self):
from scripts.migrate_db import make_url
assert make_url('mysql', '10.0.0.1', 3306, 'src_db', 'root', 'pwd') \
== 'mysql+pymysql://root:pwd@10.0.0.1:3306/src_db?charset=utf8mb4'
def test_make_url_postgresql(self):
from scripts.migrate_db import make_url
assert make_url('postgresql', '10.0.0.1', 5432, 'dst_db', 'pg', 'pwd') \
== 'postgresql+psycopg2://pg:pwd@10.0.0.1:5432/dst_db'
def test_make_url_oracle_service_name(self):
from scripts.migrate_db import make_url
assert make_url('oracle', '10.0.0.1', 1521, 'XEPDB1', 'system', 'pwd') \
== 'oracle+oracledb://system:pwd@10.0.0.1:1521/?service_name=XEPDB1'
def test_make_url_mssql(self):
from scripts.migrate_db import make_url
assert make_url('mssql', '10.0.0.1', 1433, 'dst_db', 'sa', 'pwd') \
== 'mssql+pymssql://sa:pwd@10.0.0.1:1433/dst_db?charset=utf8'
def test_make_url_sqlite_path(self):
from scripts.migrate_db import make_url
assert make_url('sqlite', '', None, r'D:\data\app.db', '', '') == 'sqlite:///D:/data/app.db'
def test_make_url_driver_alias(self):
from scripts.migrate_db import make_url
# 别名:postgres→postgresql、sqlserver→mssql
assert make_url('postgres', '10.0.0.1', 5432, 'dst_db', 'pg', 'pwd') \
== 'postgresql+psycopg2://pg:pwd@10.0.0.1:5432/dst_db'
assert make_url('sqlserver', '10.0.0.1', 1433, 'dst_db', 'sa', 'pwd') \
== 'mssql+pymssql://sa:pwd@10.0.0.1:1433/dst_db?charset=utf8'
class TestFriendlyDbError:
"""数据库连接异常友好提示(翻译认证失败/库不存在/拒连)"""
def test_auth_failed_gbk(self):
from config import friendly_db_error
gbk = '用户 "postgres" Password 认证失败'.encode('gbk')
e = UnicodeDecodeError('utf-8', gbk, 0, len(gbk), 'invalid continuation byte')
assert '认证失败' in friendly_db_error(e)
assert 'DB_PASSWORD' in friendly_db_error(e)
def test_db_not_exist(self):
from config import friendly_db_error
assert '数据库不存在' in friendly_db_error(Exception('database "x" does not exist'))
def test_connection_refused(self):
from config import friendly_db_error
assert '连接被拒绝' in friendly_db_error(Exception('Connection refused'))
def test_oracle_auth_failed(self):
from config import friendly_db_error
assert '认证失败' in friendly_db_error(Exception('ORA-01017: invalid username/password; logon denied'))
def test_oracle_service_name_error(self):
from config import friendly_db_error
msg = friendly_db_error(Exception('ORA-12514: TNS:listener does not currently know of service requested'))
assert '服务名' in msg
assert 'SERVICE_NAME' in msg
def test_oracle_connection_refused(self):
from config import friendly_db_error
assert '连接被拒绝' in friendly_db_error(Exception('ORA-12170: TNS:Connect timeout occurred'))
def test_unknown_error(self):
from config import friendly_db_error
assert 'boom' in friendly_db_error(Exception('boom'))
+145
View File
@@ -0,0 +1,145 @@
"""
共享 Excel 工具单元测试(excel_utils)
=============================================================
验证通用 Excel 导入/导出机制(供职级/用户等模块复用):
- parse_excel_rows:按列定义读取,必填/解析错误逐行收集,空行跳过
- parse_int / parse_status / format_status:单元格解析与状态展示格式化
- build_export_rows:按列 key 取值并应用 formatter
- save_export_file:写工作簿、生成访问 URL(路径通过 monkeypatch 指向临时目录)
不依赖具体业务模块,列定义在用例内自构造。
"""
import pytest
from openpyxl.reader.excel import load_workbook
from openpyxl.workbook import Workbook
from src.common.utils import excel_utils
# 用例自构造的列定义(结构与 level/user 模块一致)
COLUMNS = [
{"title": "名称", "index": 1, "key": "name", "required": True},
{"title": "排序", "index": 2, "key": "sort", "parser": excel_utils.parse_int},
{"title": "状态", "index": 3, "key": "status", "parser": excel_utils.parse_status},
]
class TestParseStatus:
"""状态单元格解析:中文 / 数字 -> 1/2,非法值报错"""
def test_normal_and_disabled(self):
assert excel_utils.parse_status("正常") == 1
assert excel_utils.parse_status("禁用") == 2
assert excel_utils.parse_status("1") == 1
assert excel_utils.parse_status(2) == 2
def test_invalid_raises(self):
with pytest.raises(ValueError):
excel_utils.parse_status("未知")
class TestParseInt:
"""整数单元格解析"""
def test_valid(self):
assert excel_utils.parse_int("10") == 10
assert excel_utils.parse_int(3) == 3
def test_invalid_raises(self):
with pytest.raises(ValueError):
excel_utils.parse_int("abc")
class TestFormatStatus:
"""状态展示格式化:1/2 -> 正常/禁用,未知值原样返回"""
def test_mapping(self):
assert excel_utils.format_status(1) == "正常"
assert excel_utils.format_status(2) == "禁用"
def test_unknown_passthrough(self):
assert excel_utils.format_status(0) == 0
class TestBuildExportRows:
"""导出行构建:按列 key 取值 + formatter 展示格式化"""
def test_formatter_applied_and_key_lookup(self):
records = [
{"name": "初级", "status": 1, "sort": 2},
{"name": "禁用级", "status": 2, "sort": 1},
]
columns = [
{"title": "名称", "key": "name"},
{"title": "排序", "key": "sort"},
{"title": "状态", "key": "status", "formatter": excel_utils.format_status},
]
assert excel_utils.build_export_rows(records, columns) == [
["初级", 2, "正常"],
["禁用级", 1, "禁用"],
]
def test_missing_key_becomes_none(self):
columns = [{"title": "缺失键", "key": "nope"}]
assert excel_utils.build_export_rows([{"name": "x"}], columns) == [[None]]
class TestParseExcelRows:
"""导入行解析:必填校验 / parser 错误 / 空行跳过 / 可选空单元格"""
def _make_wb(self, path):
wb = Workbook()
ws = wb.worksheets[0]
ws.append(["名称", "排序", "状态"]) # 表头(start_row=2 跳过)
ws.append(["初级", 1, "正常"]) # 合法
ws.append(["坏排序", "abc", "正常"]) # 排序解析失败
ws.append(["缺状态", 2, ""]) # 可选空单元格 -> None
ws.append([None, None, None]) # 整行为空 -> 跳过
ws.append(["", 3, "禁用"]) # 必填缺失
wb.save(path)
def test_rows_and_errors(self, tmp_path):
path = tmp_path / "in.xlsx"
self._make_wb(path)
rows, errors = excel_utils.parse_excel_rows(str(path), COLUMNS)
# 合法行 + 可选空单元格行保留;错误行 / 空行被剔除
assert rows == [
{"name": "初级", "sort": 1, "status": 1},
{"name": "缺状态", "sort": 2, "status": None},
]
# 行级错误带行号与原因
assert any("第3行 排序:" in e for e in errors)
assert any("第6行 名称不能为空" in e for e in errors)
class TestSaveExportFile:
"""导出文件落盘与 URL 生成"""
def test_writes_file_and_returns_url(self, tmp_path, monkeypatch):
monkeypatch.setattr(excel_utils, "FLASK_TEMP_PATH", str(tmp_path))
monkeypatch.setattr(excel_utils, "get_file_url", lambda url: "/static" + url)
headers = ["名称", "状态"]
rows = [["初级", "正常"], ["高级", "禁用"]]
url = excel_utils.save_export_file(headers, rows, filename_prefix="level")
# URL 由 get_file_url 拼接(返回约定前缀 + 路径)
assert url.startswith("/static")
# 文件真实落盘到临时目录,表头与数据一致
import glob
saved = glob.glob(str(tmp_path) + "/**/*.xlsx", recursive=True)
assert len(saved) == 1
wb = load_workbook(saved[0])
ws = wb.worksheets[0]
assert list(ws.iter_rows(values_only=True)) == [
("名称", "状态"),
("初级", "正常"),
("高级", "禁用"),
]
wb.close()
+47
View File
@@ -0,0 +1,47 @@
"""
save_file 安全修复测试
=============================================================
验证临时文件迁移的防护与正式路径保留:
- FLASK_FILE_URL 为空时拒绝外链(原 find('')==0 恒真,外链被当本地文件)
- URL 前缀不匹配时拒绝
- 路径穿越(.. 逃逸临时目录)拒绝
- 正式路径(不含 /temp)原样保留:相对路径(如 /images/notice/...)编辑回传
不被清空,带前缀的绝对地址只去前缀,外链绝对地址仍拒绝
"""
from src.common.utils import rich_text
def test_external_url_rejected_when_prefix_empty(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', '')
assert rich_text.save_file('http://evil.com/x.jpg', 'user') == ""
def test_external_url_nonmatching_prefix_rejected(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site')
assert rich_text.save_file('http://evil.com/x.jpg', 'user') == ""
def test_path_traversal_rejected(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site')
# realpath 规范化后逃逸临时目录 → 拒绝
assert rich_text.save_file('http://img.site/temp/../../../../../../etc/passwd', 'user') == ""
def test_non_temp_url_returns_stripped_path(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site')
# 已是正式路径(无 /temp)→ 去掉前缀返回,不迁移
assert rich_text.save_file('http://img.site/user/avatar.jpg', 'user') == "/user/avatar.jpg"
def test_non_temp_relative_path_kept_when_prefix_mismatch(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site')
# 编辑回传已保存的相对路径(如通知封面),前缀不匹配也应原样保留,不置空
assert rich_text.save_file('/images/notice/20260314/x.png', 'notice') == "/images/notice/20260314/x.png"
def test_non_temp_relative_path_kept_when_prefix_empty(monkeypatch):
monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', '')
# 未配置文件域名时,相对路径同样原样保留
assert rich_text.save_file('/notice/20260314/x.png', 'notice') == "/notice/20260314/x.png"