签入版本

This commit is contained in:
2026-09-12 18:57:19 +08:00
parent d45b61d808
commit 2857e0f2da
295 changed files with 20713 additions and 13125 deletions
+32
View File
@@ -38,3 +38,35 @@ def flask_app():
app.config['SECRET_KEY'] = 'test-secret-key'
app.config['WTF_CSRF_ENABLED'] = False
return app
@pytest.fixture
def app():
"""
sqlite 内存库应用实例:供 Repository 单测使用。
- StaticPool 保证所有连接共享同一内存库(多线程/多次 init 下数据一致)
- 每测试独立建表/drop(query.update / bulk_insert 与 identity map 冲突,
不宜采用事务回滚隔离)
- 测试内数据构造需显式 db.session.commit()
"""
from sqlalchemy.pool import StaticPool
from src.core.database import db
import src.models_registry # noqa: F401 (注册全部表)
flask_app = Flask(__name__)
flask_app.config.update(
TESTING=True,
SECRET_KEY='test-secret-key',
WTF_CSRF_ENABLED=False,
SQLALCHEMY_DATABASE_URI='sqlite://',
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SQLALCHEMY_ENGINE_OPTIONS={'poolclass': StaticPool},
)
db.init_app(flask_app)
with flask_app.app_context():
db.create_all()
yield flask_app
db.session.remove()
db.drop_all()
@@ -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
+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"
@@ -0,0 +1,106 @@
"""
JWT 滑动会话单元测试
=============================================================
验证:
- create_session:登录后写入 Redis 会话键,TTL = 空闲超时
- touch_session:活跃请求重置会话 TTL(返回 True)
- touch_session:空闲超时后会话键缺失 → 返回 False(需重新登录)
- touch_session:Redis 异常时 fail-closed(返回 False)
- 会话键基于 Token SHA256 指纹,不落完整 Token
测试不依赖真实 Redis,通过 monkeypatch 注入内存假 Redis。
"""
import pytest
from src.core import jwt as jwt_module
from src.core import redis_client as redis_client_module
class FakeRedis:
"""内存版 Redis 桩:仅支持滑动会话用到的 set/expire"""
def __init__(self):
self.store = {}
self.ttls = {}
def set(self, key, value, expire=None):
self.store[key] = value
if expire is not None:
self.ttls[key] = expire
return True
def expire(self, key, seconds):
if key not in self.store:
return False
self.ttls[key] = seconds
return True
class FailingRedis:
"""模拟 Redis 故障:expire 抛异常"""
def set(self, key, value, expire=None):
raise ConnectionError("redis down")
def expire(self, key, seconds):
raise ConnectionError("redis down")
@pytest.fixture
def fake_redis(monkeypatch):
fake = FakeRedis()
monkeypatch.setattr(redis_client_module, "redis", fake)
return fake
def test_session_key_is_fingerprint_based():
"""会话键基于 Token 指纹,不含完整 Token"""
key = jwt_module._session_key("a" * 200)
assert key.startswith("jwt:session:")
assert len(key) > len("jwt:session:") # 含 SHA256 摘要
assert "a" * 200 not in key # 未落完整 Token
def test_create_session_sets_ttl(fake_redis):
token = "some-jwt-token"
assert jwt_module.create_session(token) is True
key = jwt_module._session_key(token)
assert fake_redis.store[key] == "1"
assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60
def test_create_session_custom_idle(fake_redis):
token = "custom-idle-token"
assert jwt_module.create_session(token, idle_minutes=5) is True
key = jwt_module._session_key(token)
assert fake_redis.ttls[key] == 5 * 60
def test_touch_session_resets_ttl(fake_redis):
token = "touch-token"
assert jwt_module.create_session(token, idle_minutes=5) is True
# 活跃请求续期:TTL 重置为完整空闲超时
assert jwt_module.touch_session(token) is True
key = jwt_module._session_key(token)
assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60
def test_touch_session_missing_key_returns_false(fake_redis):
"""空闲超时后会话键已消失 → touch_session 返回 False(需重新登录)"""
assert jwt_module.touch_session("never-logged-in-token") is False
assert fake_redis.store == {}
def test_touch_session_fail_closed_on_redis_error(monkeypatch):
"""Redis 故障时 touch_session 返回 False(fail-closed)"""
monkeypatch.setattr(redis_client_module, "redis", FailingRedis())
assert jwt_module.touch_session("any-token") is False
def test_create_session_fail_closed_on_redis_error(monkeypatch):
"""Redis 故障时 create_session 返回 False"""
monkeypatch.setattr(redis_client_module, "redis", FailingRedis())
assert jwt_module.create_session("any-token") is False
+93
View File
@@ -0,0 +1,93 @@
"""
中间件单元测试
=============================================================
验证:
- 敏感字段递归脱敏(密码/salt/令牌等不会写入操作日志)
- 操作日志标题:@operation_log 声明的元数据被 _build_title 读取;未标注走兜底
"""
from src.middleware import _is_excluded_path, _redact_sensitive, _build_title, operation_log
class TestRedactSensitive:
"""递归脱敏"""
def test_redacts_sensitive_keys(self):
data = {
"username": "admin",
"password": "plaintext123",
"code": "business-code", # 业务字段(role.code 等)不得误打码
"key": "business-key",
"nested": {"salt": "s1", "ok": 1},
"list": [{"token": "jwt"}, "plain"],
}
result = _redact_sensitive(data)
assert result["password"] == "******"
assert result["code"] == "business-code" # 不再误脱敏
assert result["key"] == "business-key"
assert result["nested"]["salt"] == "******"
assert result["nested"]["ok"] == 1
assert result["list"][0]["token"] == "******"
assert result["list"][1] == "plain"
assert result["username"] == "admin"
def test_non_dict_passthrough(self):
assert _redact_sensitive("plain") == "plain"
assert _redact_sensitive(123) == 123
assert _redact_sensitive(None) is None
class TestBuildTitle:
"""@operation_log 元数据解析 + 兜底"""
def test_uses_view_metadata(self, flask_app):
@flask_app.route('/test/add', methods=['POST'])
@operation_log("测试模块", "新增")
def add():
return 'ok'
with flask_app.app_context():
# 裸 Flask app 的 endpoint 即视图函数名
title = _build_title('/test/add', 'add')
assert title == '测试模块-新增'
def test_action_only_uses_module(self, flask_app):
@flask_app.route('/test/list', methods=['GET'])
@operation_log("测试模块")
def lst():
return 'ok'
with flask_app.app_context():
title = _build_title('/test/list', 'lst')
assert title == '测试模块'
def test_unannotated_fallback(self, flask_app):
with flask_app.app_context():
title = _build_title('/api/v1/user/add', 'unannotated.endpoint')
assert title == 'user-add'
class TestExcludedPath:
"""敏感端点尾段匹配:兼容任意版本前缀"""
def test_v1_paths_excluded(self):
assert _is_excluded_path("/api/v1/login")
assert _is_excluded_path("/api/v1/captcha")
assert _is_excluded_path("/api/v1/logout")
assert _is_excluded_path("/api/v1/job/sendSms")
def test_v2_prefix_also_excluded(self):
# 尾段匹配 → 后续 v2/v3 无需追加前缀配置
assert _is_excluded_path("/api/v2/login")
assert _is_excluded_path("/api/v2/job/sendSms")
def test_normal_path_not_excluded(self):
assert not _is_excluded_path("/api/v1/user/add")
assert not _is_excluded_path("/api/v1/job/page")
assert not _is_excluded_path("/api/v1/job/runOnce/1")
def test_root_mounted_paths_excluded(self):
assert _is_excluded_path("/login")
assert _is_excluded_path("/captcha")
@@ -0,0 +1,118 @@
"""
城市 full_name/pinyin 自动补全测试
=============================================================
验证新增/编辑城市时自动补全派生字段:
- full_name:沿 pid 链从首层城市拼接(如 台湾省,云林,元长,华鹿)
- pinyin:由城市简称生成无音调拼音,音节首字母大写(如 台湾 -> TaiWan、华鹿 -> HuaLu)
覆盖新增、编辑改名、编辑变更父级三种场景。
"""
from src.core.database import db
from src.modules.city.repository import CityRepository
from src.modules.city.service import CityService
def _payload(name, short_name, pid, level):
"""构造合法的城市表单数据(不含派生字段)"""
return {
"cityCode": "012",
"areaCode": "640300",
"parentCode": "0",
"zipCode": "750000",
"level": level,
"name": name,
"shortName": short_name,
"pid": pid,
"lng": "106.2",
"lat": "23.7",
}
def _add_city(app, name, short_name, pid, level):
"""经 CityService.add 新增城市并返回其ID"""
with app.app_context():
CityService.add(_payload(name, short_name, pid, level))
db.session.commit()
return CityRepository.first_by({"name": name}).id
class TestCityFullName:
"""full_name 沿 pid 链从首层拼接"""
def test_full_name_chain(self, app):
province_id = _add_city(app, "台湾省", "台湾", 0, 1)
city_id = _add_city(app, "云林", "云林", province_id, 2)
district_id = _add_city(app, "元长", "元长", city_id, 3)
street_id = _add_city(app, "华鹿", "华鹿", district_id, 4)
with app.app_context():
province = CityRepository.get_by_id(province_id)
city = CityRepository.get_by_id(city_id)
district = CityRepository.get_by_id(district_id)
street = CityRepository.get_by_id(street_id)
assert province.full_name == "台湾省"
assert city.full_name == "台湾省,云林"
assert district.full_name == "台湾省,云林,元长"
assert street.full_name == "台湾省,云林,元长,华鹿"
def test_update_rename_recomputes(self, app):
province_id = _add_city(app, "广东省", "广东", 0, 1)
city_id = _add_city(app, "广州市", "广州", province_id, 2)
with app.app_context():
data = _payload("东莞市", "东莞", province_id, 2)
data["id"] = city_id
CityService.update(data)
db.session.commit()
db.session.expire_all()
city = CityRepository.get_by_id(city_id)
assert city.name == "东莞市"
assert city.full_name == "广东省,东莞市"
def test_update_reparent_recomputes(self, app):
prov_a = _add_city(app, "A省", "A省", 0, 1)
prov_b = _add_city(app, "B省", "B省", 0, 1)
city_id = _add_city(app, "旧城", "旧城", prov_a, 2)
with app.app_context():
data = _payload("新城", "新城", prov_b, 2)
data["id"] = city_id
CityService.update(data)
db.session.commit()
db.session.expire_all()
city = CityRepository.get_by_id(city_id)
assert city.full_name == "B省,新城"
class TestCityPinyin:
"""pinyin 由简称生成(无音调)"""
def test_pinyin_uses_short_name(self, app):
province_id = _add_city(app, "台湾省", "台湾", 0, 1)
city_id = _add_city(app, "云林", "云林", province_id, 2)
with app.app_context():
province = CityRepository.get_by_id(province_id)
city = CityRepository.get_by_id(city_id)
assert province.pinyin == "TaiWan"
assert city.pinyin == "YunLin"
def test_update_rename_recomputes_pinyin(self, app):
province_id = _add_city(app, "广东省", "广东", 0, 1)
city_id = _add_city(app, "广州市", "广州", province_id, 2)
with app.app_context():
data = _payload("深圳市", "深圳", province_id, 2)
data["id"] = city_id
CityService.update(data)
db.session.commit()
db.session.expire_all()
city = CityRepository.get_by_id(city_id)
assert city.pinyin == "ShenZhen"
+83
View File
@@ -0,0 +1,83 @@
"""
城市列表 pid 空值回归测试(空串/缺失 → 首层 pid=0,杜绝全量查询)
=============================================================
回归依据:find_list_params 原实现 `int('')` 抛 ValueError 后落到 pid=None,
而 BaseRepository._build_query 对 None 过滤值静默跳过 → 空串 pid 触发全表查询。
城市为树形结构,首层必须 pid=0;修复后空/缺失/非法 pid 统一回落到首层。
"""
from src.core.database import db
from src.modules.city import service as city_service
from src.modules.city.repository import CityRepository
def _seed_cities(app):
"""构造 省(pid=0) × 2 + 市(pid=省id) × 1,返回各城市ID"""
with app.app_context():
bj = CityRepository.create(
city_code="010", area_code="110000", parent_code="0", zip_code="100000",
level=1, name="北京市", short_name="北京", pid=0,
)
hb = CityRepository.create(
city_code="027", area_code="420000", parent_code="0", zip_code="430000",
level=1, name="湖北省", short_name="湖北", pid=0,
)
wh = CityRepository.create(
city_code="027", area_code="420100", parent_code="420000", zip_code="430000",
level=2, name="武汉市", short_name="武汉", pid=hb.id,
)
db.session.commit()
return {"bj": bj.id, "hb": hb.id, "wh": wh.id}
def _names(items):
return sorted(c.name for c in items)
class TestCityListPid:
"""pid 空串/缺失/非法 → 首层 pid=0;合法 pid → 对应子级"""
def test_empty_string_returns_first_layer_only(self, app):
_seed_cities(app)
with app.app_context():
items = CityRepository.find_list_params({"pid": ""})
# 空串 pid 只返回 pid=0 首层,不再全表查询
assert _names(items) == ["北京市", "湖北省"]
def test_zero_missing_empty_equivalent(self, app):
_seed_cities(app)
with app.app_context():
empty = _names(CityRepository.find_list_params({"pid": ""}))
zero = _names(CityRepository.find_list_params({"pid": 0}))
missing = _names(CityRepository.find_list_params({}))
assert empty == zero == missing == ["北京市", "湖北省"]
def test_invalid_pid_falls_back_to_first_layer(self, app):
_seed_cities(app)
with app.app_context():
items = CityRepository.find_list_params({"pid": "abc"})
assert _names(items) == ["北京市", "湖北省"]
def test_valid_pid_returns_children(self, app):
ids = _seed_cities(app)
with app.app_context():
items = CityRepository.find_list_params({"pid": str(ids["hb"])})
assert _names(items) == ["武汉市"]
def test_pid_empty_combined_with_name(self, app):
_seed_cities(app)
with app.app_context():
items = CityRepository.find_list_params({"pid": "", "name": "湖北"})
assert _names(items) == ["湖北省"]
class TestGetCityListService:
"""服务层端到端:空串 pid 只返回首层"""
def test_service_empty_pid_returns_first_layer(self, app):
_seed_cities(app)
with app.app_context():
data = city_service.get_city_list({"pid": ""})
assert sorted(d["name"] for d in data) == ["北京市", "湖北省"]
assert all(d["pid"] == 0 for d in data)
@@ -0,0 +1,199 @@
"""
字典项/配置项名称唯一性测试(同分组下唯一,跨分组允许重名)
=============================================================
验证名称唯一性按父级分组(dict_id / config_id)限定:
- 同一分组下名称不能重复(新增、编辑均校验)
- 不同分组之间允许存在相同名称
- 更新时保留自身名称不误报(排除自身)
回归依据:修复前 validate_name 按全局名称去重,导致不同字典/配置下
的同名子项被误拒;且与 validate_value 的「同分组唯一」语义不一致。
"""
import pytest
from src.core.database import db
from src.core.exceptions import BizError
from src.modules.configuration.config_item.repository import ConfigItemRepository
from src.modules.configuration.config.repository import ConfigRepository
from src.modules.configuration.config_item.service import ConfigItemService
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
# ============================================================
# 字典项:名称同 dict_id 下唯一,跨 dict_id 允许重名
# ============================================================
def _seed_dict(app, name="性别", code="gender"):
"""创建字典类型并返回其ID"""
with app.app_context():
dict_obj = DictRepository.create(name=name, code=code, sort=1)
db.session.commit()
return dict_obj.id
def _add_dict_item(app, dict_id, name, value, sort=1):
"""经 DictItemService.add 新增字典项"""
with app.app_context():
DictItemService.add({"dictId": dict_id, "name": name, "value": value, "sort": sort})
db.session.commit()
class TestDictItemNameUniqueWithinDict:
"""字典项名称:同 dict_id 下唯一,不同 dict_id 允许重名"""
def test_same_name_same_dict_rejected(self, app):
dict_id = _seed_dict(app)
_add_dict_item(app, dict_id, "男", "1")
with app.app_context():
with pytest.raises(BizError, match="字典项名称已存在"):
DictItemService.add({"dictId": dict_id, "name": "男", "value": "2"})
def test_same_name_different_dict_allowed(self, app):
dict_a = _seed_dict(app, name="性别", code="gender")
dict_b = _seed_dict(app, name="用户性别", code="user_gender")
_add_dict_item(app, dict_a, "男", "1")
with app.app_context():
DictItemService.add({"dictId": dict_b, "name": "男", "value": "1"})
db.session.commit()
items = DictItemRepository.find_list({"name": "男"})
ids = sorted(i.dict_id for i in items)
assert ids == [dict_a, dict_b]
def test_update_keep_own_name_allowed(self, app):
dict_id = _seed_dict(app)
_add_dict_item(app, dict_id, "男", "1")
with app.app_context():
item = DictItemRepository.first_by({"dict_id": dict_id, "name": "男"})
# 编辑时保留自身名称 → 排除自身后无重复,校验通过
DictItemService.update({"id": item.id, "dictId": dict_id, "name": "男", "value": "1"})
db.session.commit()
def test_update_to_duplicate_in_same_dict_rejected(self, app):
dict_id = _seed_dict(app)
_add_dict_item(app, dict_id, "男", "1")
_add_dict_item(app, dict_id, "女", "2")
with app.app_context():
item = DictItemRepository.first_by({"dict_id": dict_id, "name": "女"})
with pytest.raises(BizError, match="字典项名称已存在"):
DictItemService.update({"id": item.id, "dictId": dict_id, "name": "男", "value": "2"})
def test_update_name_duplicate_across_dict_allowed(self, app):
dict_a = _seed_dict(app, name="性别", code="gender")
dict_b = _seed_dict(app, name="用户性别", code="user_gender")
_add_dict_item(app, dict_a, "男", "1")
_add_dict_item(app, dict_b, "女", "2")
with app.app_context():
item = DictItemRepository.first_by({"dict_id": dict_b, "name": "女"})
# 改为另一字典已有的名称 → 跨分组允许
DictItemService.update({"id": item.id, "dictId": dict_b, "name": "男", "value": "2"})
db.session.commit()
items = DictItemRepository.find_list({"name": "男"})
ids = sorted(i.dict_id for i in items)
assert ids == [dict_a, dict_b]
# ============================================================
# 配置项:名称同 config_id 下唯一,跨 config_id 允许重名
# ============================================================
def _seed_config(app, name="网站配置", code="site"):
"""创建参数配置并返回其ID"""
with app.app_context():
config = ConfigRepository.create(name=name, code=code, sort=1)
db.session.commit()
return config.id
def _add_config_item(app, config_id, name, code, value="v", type_="text", sort=1):
"""经 ConfigItemService.add 新增配置项(code 全局唯一,跨配置需不同编码)"""
with app.app_context():
ConfigItemService.add({
"configId": config_id,
"name": name,
"code": code,
"value": value,
"type": type_,
"sort": sort,
})
db.session.commit()
class TestConfigItemNameUniqueWithinConfig:
"""配置项名称:同 config_id 下唯一,不同 config_id 允许重名"""
def test_same_name_same_config_rejected(self, app):
config_id = _seed_config(app)
_add_config_item(app, config_id, "网站名称", "site_name")
with app.app_context():
with pytest.raises(BizError, match="配置项名称已存在"):
ConfigItemService.add({
"configId": config_id,
"name": "网站名称",
"code": "site_name_2",
"value": "v2",
"type": "text",
})
def test_same_name_different_config_allowed(self, app):
config_a = _seed_config(app, name="网站配置", code="site")
config_b = _seed_config(app, name="应用配置", code="app")
_add_config_item(app, config_a, "网站名称", "site_name")
with app.app_context():
ConfigItemService.add({
"configId": config_b,
"name": "网站名称",
"code": "app_name",
"value": "v",
"type": "text",
})
db.session.commit()
items = ConfigItemRepository.find_list({"name": "网站名称"})
ids = sorted(i.config_id for i in items)
assert ids == [config_a, config_b]
def test_update_keep_own_name_allowed(self, app):
config_id = _seed_config(app)
_add_config_item(app, config_id, "网站名称", "site_name")
with app.app_context():
item = ConfigItemRepository.first_by({"config_id": config_id, "name": "网站名称"})
# 编辑时保留自身名称 → 校验通过
ConfigItemService.update({
"id": item.id, "configId": config_id,
"name": "网站名称", "code": "site_name", "value": "v", "type": "text",
})
db.session.commit()
def test_update_to_duplicate_in_same_config_rejected(self, app):
config_id = _seed_config(app)
_add_config_item(app, config_id, "网站名称", "site_name")
_add_config_item(app, config_id, "联系电话", "contact_phone")
with app.app_context():
item = ConfigItemRepository.first_by({"config_id": config_id, "name": "联系电话"})
with pytest.raises(BizError, match="配置项名称已存在"):
ConfigItemService.update({
"id": item.id, "configId": config_id,
"name": "网站名称", "code": "contact_phone", "value": "v", "type": "text",
})
def test_update_name_duplicate_across_config_allowed(self, app):
config_a = _seed_config(app, name="网站配置", code="site")
config_b = _seed_config(app, name="应用配置", code="app")
_add_config_item(app, config_a, "网站名称", "site_name")
_add_config_item(app, config_b, "联系电话", "contact_phone")
with app.app_context():
item = ConfigItemRepository.first_by({"config_id": config_b, "name": "联系电话"})
# 改为另一配置已有的名称 → 跨分组允许
ConfigItemService.update({
"id": item.id, "configId": config_b,
"name": "网站名称", "code": "contact_phone", "value": "v", "type": "text",
})
db.session.commit()
items = ConfigItemRepository.find_list({"name": "网站名称"})
ids = sorted(i.config_id for i in items)
assert ids == [config_a, config_b]
+114
View File
@@ -0,0 +1,114 @@
"""
字典取值工具单元测试(dict_utils)
=============================================================
验证 dict_utils 按 code 取字典项 / value→name 映射(消费端取值):
- int / str 值都能命中(value 统一 str 归一)
- 未知 code / 未知 value → None
- 内存缓存命中(第二次取值不再查库)
- clear_cache 失效后重新加载
"""
import pytest
from src.core.database import db
from src.modules.dictionary import dict_utils
from src.modules.dictionary.dict.repository import DictRepository
from src.modules.dictionary.dict_item.repository import DictItemRepository
@pytest.fixture(autouse=True)
def _clear_dict_cache():
"""每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存"""
dict_utils.clear_cache()
yield
dict_utils.clear_cache()
def _seed_gender():
"""构造 gender 字典(男/"1"、女/"2"、保密/"3"),幂等"""
if DictRepository.first_by({"code": "gender"}):
return
dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system")
for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]:
DictItemRepository.create(
dict_id=dict_obj.id, name=name, value=value,
sort=sort, create_user="system",
)
db.session.commit()
class TestGetName:
"""int / str 值命中与未知值兜底"""
def test_int_value_hits(self, app):
with app.app_context():
_seed_gender()
assert dict_utils.get_name("gender", 1) == "男"
assert dict_utils.get_name("gender", 2) == "女"
assert dict_utils.get_name("gender", 3) == "保密"
def test_str_value_hits(self, app):
with app.app_context():
_seed_gender()
assert dict_utils.get_name("gender", "1") == "男"
assert dict_utils.get_name("gender", "2") == "女"
def test_unknown_value_returns_none(self, app):
with app.app_context():
_seed_gender()
assert dict_utils.get_name("gender", 99) is None
def test_unknown_code_returns_none(self, app):
with app.app_context():
assert dict_utils.get_name("not_exist", 1) is None
class TestGetValueMap:
"""映射键统一为 str"""
def test_map_keys_are_str(self, app):
with app.app_context():
_seed_gender()
value_map = dict_utils.get_value_map("gender")
assert value_map == {"1": "男", "2": "女", "3": "保密"}
class TestCache:
"""内存缓存命中 + clear_cache 失效"""
def test_cache_hit_queries_db_once(self, app, monkeypatch):
with app.app_context():
_seed_gender()
original = DictItemRepository.list_by_code
calls = {"n": 0}
def spy(cls, code, **kwargs):
calls["n"] += 1
return original(code, **kwargs)
monkeypatch.setattr(DictItemRepository, "list_by_code", classmethod(spy))
assert dict_utils.get_name("gender", 1) == "男"
assert dict_utils.get_name("gender", 2) == "女"
assert dict_utils.get_name("gender", 3) == "保密"
assert calls["n"] == 1
def test_clear_cache_reloads(self, app):
with app.app_context():
_seed_gender()
assert dict_utils.get_name("gender", 1) == "男"
dict_utils.clear_cache("gender")
# 失效后重新读取仍正确
assert dict_utils.get_name("gender", 2) == "女"
class TestGetDictItems:
"""字典项列表返回 {value, name, sort}"""
def test_items_shape_and_order(self, app):
with app.app_context():
_seed_gender()
items = dict_utils.get_dict_items("gender")
assert [it["value"] for it in items] == ["1", "2", "3"]
assert items[0] == {"value": "1", "name": "男", "sort": 1}
@@ -0,0 +1,69 @@
"""
文件模板服务单元测试(按编码查询 + 分页 filePath URL 拼接)
=============================================================
验证:
- get_file_template_by_code 按编码返回数据,filePath 经 get_file_url 拼接
- 编码不存在返回 None
- get_page 分页记录的 filePath 同样做 URL 拼接
- 编码查询与详情返回结构一致
"""
from config import FLASK_FILE_URL
from src.core.database import db
from src.modules.file_template import service as file_template
from src.modules.file_template.repository import FileTemplateRepository
def _create_template(app, code="import_user", name="用户导入模板", file_path="/static/temp/imp.xlsx"):
with app.app_context():
obj = FileTemplateRepository.create(
name=name, code=code, file_name="imp.xlsx",
file_path=file_path, note="备注", sort=1,
)
db.session.commit()
return obj.id
class TestGetByCode:
"""按模板编码查询,filePath 转 URL"""
def test_by_code_returns_url_joined_path(self, app):
tid = _create_template(app)
with app.app_context():
data = file_template.get_file_template_by_code("import_user")
assert data is not None
assert data["id"] == tid
assert data["name"] == "用户导入模板"
assert data["filePath"] == FLASK_FILE_URL + "/static/temp/imp.xlsx"
def test_by_code_missing_returns_none(self, app):
with app.app_context():
assert file_template.get_file_template_by_code("not_exist") is None
def test_by_code_matches_detail_shape(self, app):
tid = _create_template(app, code="detail_same")
with app.app_context():
by_code = file_template.get_file_template_by_code("detail_same")
detail = file_template.get_file_template_detail(tid)
assert by_code == detail
class TestPageUrlJoin:
"""分页记录 filePath 做 URL 拼接"""
def test_page_file_path_url_joined(self, app):
_create_template(app)
with app.app_context():
data = file_template.get_file_template_page({"pageNo": 1, "pageSize": 10})
assert data["total"] == 1
record = data["records"][0]
assert record["filePath"] == FLASK_FILE_URL + "/static/temp/imp.xlsx"
def test_page_empty_file_path_returns_none(self, app):
_create_template(app, file_path=None)
with app.app_context():
data = file_template.get_file_template_page({"pageNo": 1, "pageSize": 10})
assert data["records"][0]["filePath"] is None
+457
View File
@@ -0,0 +1,457 @@
"""
代码生成器模块单元测试
=============================================================
验证代码生成器 generator 模块的核心纯函数(无需数据库):
- 表分页:_paginate_tables(总数/页数/切片/越界回退)
- 表项解析:_parse_table_item(管道分隔/纯表名/字典三种格式)
- 生成入口校验:generate_tables(空/非数组/超限抛 BizError,单项失败继续)
- 字段类型映射:TableSchemaParser(DB 类型 -> SQLAlchemy 类型、长度、choices)
- 代码块构建:_build_column_block / _build_form_field_block / _build_filters_literal
- 自动注册:_insert_module_import / _insert_blueprint(插入与幂等)
"""
import pytest
from src.core.exceptions import BizError
from src.modules.generator import service as gen
from src.modules.generator.code_generator import (
CodeGenerator,
_build_column_block,
_build_filters_literal,
_build_form_field_block,
_choice_tag_type,
normalize_config,
)
from src.modules.generator.parser import TableSchemaParser
# ============================================================
# 表分页
# ============================================================
class TestPaginateTables:
"""内存分页:切片 + 页数计算 + 越界回退"""
def test_basic(self):
tables = [{'tableName': f't{i}'} for i in range(25)]
result = gen._paginate_tables(tables, 1, 10)
assert result['total'] == 25
assert result['pages'] == 3
assert result['size'] == 10
assert result['current'] == 1
assert len(result['records']) == 10
assert result['records'][0]['tableName'] == 't0'
def test_last_page(self):
tables = [{'tableName': f't{i}'} for i in range(25)]
result = gen._paginate_tables(tables, 3, 10)
assert result['current'] == 3
assert len(result['records']) == 5
def test_page_out_of_range_falls_back_to_first(self):
tables = [{'tableName': 't0'}] * 5
result = gen._paginate_tables(tables, 99, 10)
assert result['current'] == 1
assert result['pages'] == 1
def test_empty(self):
result = gen._paginate_tables([], 1, 10)
assert result['total'] == 0
assert result['pages'] == 0
assert result['records'] == []
# ============================================================
# 表项解析
# ============================================================
class TestParseTableItem:
"""tableNames 项三种格式解析"""
def test_pipe_format(self):
assert gen._parse_table_item('flask_article|文章管理表') == ('flask_article', '文章管理表')
def test_plain_name(self):
assert gen._parse_table_item('flask_article') == ('flask_article', '')
def test_pipe_with_empty_comment(self):
assert gen._parse_table_item('flask_article|') == ('flask_article', '')
def test_dict_format(self):
assert gen._parse_table_item({'name': 'flask_user', 'comment': '用户表'}) == ('flask_user', '用户表')
# ============================================================
# 生成入口校验
# ============================================================
class TestGenerateTables:
"""参数校验与批量容错"""
def test_missing_key_raises(self):
with pytest.raises(BizError, match='表名不能为空'):
gen.generate_tables({})
def test_empty_list_raises(self):
with pytest.raises(BizError, match='表名不能为空'):
gen.generate_tables({'tableNames': []})
def test_not_list_raises(self):
with pytest.raises(BizError, match='必须是数组'):
gen.generate_tables({'tableNames': 'flask_article'})
def test_too_many_raises(self):
names = [f't{i}' for i in range(51)]
with pytest.raises(BizError, match='最多支持'):
gen.generate_tables({'tableNames': names})
def test_success_returns_count(self, monkeypatch):
monkeypatch.setattr(gen, '_generate_one', lambda name, comment: None)
count = gen.generate_tables({'tableNames': ['flask_a|A表', 'flask_b']})
assert count == 2
def test_partial_failure_continues(self, monkeypatch):
def fake_generate(table_name, table_comment):
if table_name == 'bad':
raise RuntimeError('boom')
monkeypatch.setattr(gen, '_generate_one', fake_generate)
count = gen.generate_tables({'tableNames': ['good|A', 'bad', 'ok']})
assert count == 2
# ============================================================
# 字段类型映射(不查库,仅构造列字典)
# ============================================================
def _col(**overrides):
"""构造 information_schema 列字典"""
base = {
'DATA_TYPE': 'varchar',
'COLUMN_NAME': 'title',
'COLUMN_COMMENT': '标题',
'IS_NULLABLE': 'NO',
'CHARACTER_MAXIMUM_LENGTH': 255,
'NUMERIC_PRECISION': None,
'NUMERIC_SCALE': None,
'COLUMN_DEFAULT': None,
'EXTRA': '',
}
base.update(overrides)
return base
class TestFieldTypeMapping:
"""DB 数据类型 -> SQLAlchemy 类型 / 构造参数 / choices"""
def test_varchar_to_string(self):
parser = TableSchemaParser('flask_t')
assert parser._get_field_type(_col(DATA_TYPE='varchar')) == 'String'
def test_text_to_text(self):
parser = TableSchemaParser('flask_t')
assert parser._get_field_type(_col(DATA_TYPE='longtext')) == 'Text'
def test_bigint_to_biginteger(self):
parser = TableSchemaParser('flask_t')
assert parser._get_field_type(_col(DATA_TYPE='bigint')) == 'BigInteger'
def test_decimal_to_numeric(self):
parser = TableSchemaParser('flask_t')
assert parser._get_field_type(_col(DATA_TYPE='decimal')) == 'Numeric'
def test_db_args_string_length(self):
parser = TableSchemaParser('flask_t')
assert parser._get_db_args(_col(DATA_TYPE='varchar', CHARACTER_MAXIMUM_LENGTH=100)) == '100'
def test_db_args_numeric_precision(self):
parser = TableSchemaParser('flask_t')
assert parser._get_db_args(_col(DATA_TYPE='decimal', NUMERIC_PRECISION=10, NUMERIC_SCALE=2)) == '10, 2'
def test_db_args_none_for_text(self):
parser = TableSchemaParser('flask_t')
assert parser._get_db_args(_col(DATA_TYPE='text')) is None
def test_form_type_integer(self):
parser = TableSchemaParser('flask_t')
assert parser._get_form_type(_col(DATA_TYPE='int')) == 'IntegerField'
def test_choices_parse(self):
parser = TableSchemaParser('flask_t')
choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='0-正常 1-下架'))
assert choices == [(0, '正常'), (1, '下架')]
assert (min_value, max_value) == (0, 1)
def test_choices_none(self):
parser = TableSchemaParser('flask_t')
choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='标题'))
assert choices is None
assert min_value is None
def test_filter_type_string_fuzzy(self):
parser = TableSchemaParser('flask_t')
assert parser._get_filter_type(_col(COLUMN_NAME='title', DATA_TYPE='varchar')) == '模糊查询'
def test_filter_type_code_exact(self):
parser = TableSchemaParser('flask_t')
assert parser._get_filter_type(_col(COLUMN_NAME='code', DATA_TYPE='varchar')) == '精确匹配'
def test_numeric_default_normalized(self):
parser = TableSchemaParser('flask_t')
col = _col(DATA_TYPE='int', COLUMN_NAME='sort', COLUMN_DEFAULT='0')
fields = parser._build_fields([col], set())
# MySQL 默认值以字符串返回,整数列应转为 int(生成 default=0 而非 default='0')
assert fields[0]['default_repr'] == '0'
assert isinstance(fields[0]['default'], int)
def test_string_default_kept_as_string(self):
parser = TableSchemaParser('flask_t')
col = _col(DATA_TYPE='varchar', COLUMN_NAME='name', COLUMN_DEFAULT="'normal'")
fields = parser._build_fields([col], set())
assert fields[0]['default'] == 'normal'
assert fields[0]['default_repr'] == "'normal'"
# ============================================================
# 代码块构建
# ============================================================
class TestBlockBuilders:
"""models/schema/repository 代码块预构建"""
def test_column_block(self):
field = dict(name='title', comment='标题', py_comment='标题', db_type='String', db_args='200',
nullable=False, default_repr=None, is_unique=False, db_index=False)
block = _build_column_block(field)
assert 'title = db.Column(' in block
assert 'db.String(200)' in block
assert 'nullable=False' in block
assert 'comment="标题"' in block
def test_column_block_with_default_unique_index(self):
field = dict(name='status', comment='状态', py_comment='状态', db_type='Integer', db_args=None,
nullable=False, default_repr='1', is_unique=True, db_index=True)
block = _build_column_block(field)
assert 'db.Integer' in block
assert 'default=1' in block
assert 'unique=True' in block
assert 'index=True' in block
def test_form_field_block(self):
field = dict(camel_name='title', form_type='StringField', comment='标题', py_comment='标题',
required=True, max_length=200, min_value=None, max_value=None)
block = _build_form_field_block(field)
assert 'title = StringField(' in block
assert "DataRequired(message='标题不能为空')" in block
assert 'Length(max=200' in block
def test_filters_literal_empty(self):
assert _build_filters_literal([]) == '{}'
def test_filters_literal(self):
literal = _build_filters_literal([('status', 'status'), ('name', 'name')])
assert "'status': params.get('status')," in literal
assert "'name': params.get('name')," in literal
# ============================================================
# 自动注册辅助
# ============================================================
# ============================================================
# 配置标准化(兼容 config.json 参考配置)
# ============================================================
class TestNormalizeConfig:
"""config.json 参考字段类型 -> SQLAlchemy 类型转换"""
def test_django_config_normalized(self):
config = {
'app_name': 'example',
'fields': [
{'name': 'name', 'type': 'CharField', 'max_length': 100, 'comment': '名称', 'default': None},
{'name': 'status', 'type': 'IntegerField', 'comment': '状态', 'default': '1'},
{'name': 'sort', 'type': 'IntegerField', 'comment': '排序', 'default': '0'},
],
}
out = normalize_config(config)
name_field = out['fields'][0]
assert name_field['db_type'] == 'String'
assert name_field['db_args'] == '100'
assert name_field['form_type'] == 'StringField'
assert name_field['camel_name'] == 'name'
status_field = out['fields'][1]
assert status_field['db_type'] == 'Integer'
assert status_field['form_type'] == 'IntegerField'
assert status_field['default'] == 1 # 字符串默认值转 int
assert status_field['default_repr'] == '1'
assert status_field['is_status'] is True
sort_field = out['fields'][2]
assert sort_field['is_sort'] is True
def test_flask_config_passthrough(self):
# 已含 db_type 的 Flask 配置原样返回(幂等)
config = {'app_name': 'x', 'fields': [{'name': 'a', 'db_type': 'String'}]}
assert normalize_config(config) is config
def test_empty_fields_passthrough(self):
config = {'app_name': 'x', 'fields': []}
assert normalize_config(config) is config
# ============================================================
# 前端 Vue 上下文
# ============================================================
def _gen_config(fields, **extra):
"""构造最小生成配置"""
base = {
'app_name': 'example', 'module_comment': '案例', 'model_class_name': 'Example',
'permission_prefix': 'sys:example', 'route_prefix': 'example', 'primary_key': 'id',
'display_field': 'name', 'display_field_camel': 'name',
'has_sort': True, 'has_status': True, 'has_unique_code': False, 'has_export': False,
'fields': fields,
}
base.update(extra)
return base
def _vf(name, db_type='String', comment='字段', choices=None, filterable=False, **kwargs):
"""构造最小字段配置"""
field = {
'name': name, 'camel_name': name, 'comment': comment, 'py_comment': comment,
'db_type': db_type, 'db_args': '100' if db_type == 'String' else None,
'form_type': 'StringField', 'type_display': '字符串',
'max_length': 100 if db_type == 'String' else None,
'nullable': False, 'required': True, 'default_repr': None, 'is_unique': False,
'db_index': False, 'default': None, 'filterable': filterable,
'filter_type': '模糊查询' if filterable else '',
'in_form': True, 'in_list': True, 'editable': True, 'searchable': filterable,
'is_image': False, 'is_rich_text': False, 'is_sort': False, 'is_status': False,
'choices': choices, 'min_value': None, 'max_value': None,
}
field.update(kwargs)
return field
class TestCleanFieldComment:
"""清洗注释中的选项说明"""
def test_strip_choices(self):
gen = CodeGenerator(_gen_config([]))
assert gen._clean_field_comment('案例类型:1-类型1 2-类型2') == '案例类型'
assert gen._clean_field_comment('案例状态:1-正常 2-禁用') == '案例状态'
def test_plain(self):
gen = CodeGenerator(_gen_config([]))
assert gen._clean_field_comment('标题') == '标题'
assert gen._clean_field_comment('') == ''
class TestBuildChoices:
"""选项元组 -> 前端字典列表"""
def test_transformed(self):
gen = CodeGenerator(_gen_config([]))
assert gen._build_choices([(1, '正常'), (2, '禁用')]) == [
{'value': 1, 'label': '正常', 'type': 'success'},
{'value': 2, 'label': '禁用', 'type': 'danger'},
]
def test_none(self):
gen = CodeGenerator(_gen_config([]))
assert gen._build_choices(None) is None
class TestChoiceTagType:
"""选项值 -> 标签颜色"""
def test_values(self):
assert _choice_tag_type(1) == 'success'
assert _choice_tag_type(2) == 'danger'
assert _choice_tag_type(3) == 'warning'
assert _choice_tag_type(9) == 'info'
class TestVueContext:
"""前端模板上下文:普通列表与树状列表"""
def test_normal_context(self):
fields = [
_vf('name', comment='案例名称', filterable=True),
_vf('status', db_type='Integer', comment='案例状态:1-正常 2-禁用',
choices=[(1, '正常'), (2, '禁用')], filterable=True),
]
gen = CodeGenerator(_gen_config(fields))
vctx = gen._prepare_vue_context(gen._prepare_context())
assert vctx['is_tree_structure'] is False
assert vctx['route_prefix'] == 'example' # 覆盖为 app_name,与后端蓝图一致
assert vctx['model_class_name_camel'] == 'example'
assert vctx['module_name'] == 'example'
assert vctx['api_path'] == 'tool/example'
# 字段重塑:type 转配置字段类型名、label 清洗、choices 转字典
name_field = next(f for f in vctx['fields'] if f['name'] == 'name')
assert name_field['type'] == 'CharField'
assert name_field['label'] == '案例名称'
status_field = next(f for f in vctx['fields'] if f['name'] == 'status')
assert status_field['type'] == 'IntegerField'
assert status_field['label'] == '案例状态'
assert status_field['choices'][0] == {'value': 1, 'label': '正常', 'type': 'success'}
def test_tree_context(self):
fields = [_vf('name', comment='部门名称'), _vf('parent_id', db_type='Integer', comment='上级部门')]
gen = CodeGenerator(_gen_config(fields))
vctx = gen._prepare_vue_context(gen._prepare_context())
assert vctx['is_tree_structure'] is True
assert vctx['parent_id_field'] == 'parentId'
assert vctx['has_search_form'] is False # 树形默认不显示搜索表单
def test_module_name_underscore_stripped(self):
gen = CodeGenerator(_gen_config([], app_name='order_item', model_class_name='OrderItem'))
vctx = gen._prepare_vue_context(gen._prepare_context())
assert vctx['module_name'] == 'orderitem'
assert vctx['model_class_name_camel'] == 'orderItem'
def test_filter_fields(self):
fields = [_vf('name', filterable=True), _vf('type', db_type='Integer', filterable=True)]
gen = CodeGenerator(_gen_config(fields))
vctx = gen._prepare_vue_context(gen._prepare_context())
assert [f['name'] for f in vctx['filter_fields']] == ['name', 'type']
assert vctx['has_search_form'] is True
class TestRegisterHelpers:
"""_insert_module_import / _insert_blueprint 插入与幂等"""
def test_insert_module_import(self):
content = "from src.modules.auth.router import login_blue\nfrom src.modules.city.router import city_blue\n"
out = CodeGenerator._insert_module_import(
content, 'from src.modules.generator.router import generator_blue'
)
assert 'from src.modules.generator.router import generator_blue\n' in out
# 插入位置应在最后一个 src.modules 导入之后
assert out.index('generator') > out.index('city_blue')
def test_insert_module_import_idempotent(self):
content = "from src.modules.city.router import city_blue\n"
out = CodeGenerator._insert_module_import(content, 'from src.modules.city.router import city_blue')
assert out == content
def test_insert_blueprint(self):
content = "BLUEPRINTS = [\n login_blue,\n city_blue,\n]\n"
out = CodeGenerator._insert_blueprint(content, 'generator_blue')
assert 'generator_blue' in out
assert out.index('generator_blue') > out.index('city_blue')
assert out.endswith(' generator_blue,\n]\n')
def test_insert_blueprint_idempotent(self):
content = "BLUEPRINTS = [\n generator_blue,\n]\n"
out = CodeGenerator._insert_blueprint(content, 'generator_blue')
assert out == content
@@ -0,0 +1,113 @@
"""
任务日志服务单元测试(job_log 模块)
=============================================================
验证 job_log 模块的查询/富化/删除契约:
- 分页返回 statusText(走字典表 job_log_status:0-正常 1-失败)
- 按 jobId / status 筛选
- 软删除
"""
from datetime import datetime
import pytest
from src.core.database import db
from src.modules.dictionary import dict_utils
from src.modules.job.repository import JobRepository
from src.modules.job_log.models import JobLog
from src.modules.job_log.repository import JobLogRepository
from src.modules.job_log.service import JobLogService, write_job_log
from scripts.seed_dict import seed_dict
@pytest.fixture(autouse=True)
def _clear_dict_cache():
"""每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存"""
dict_utils.clear_cache()
yield
dict_utils.clear_cache()
def _create_job_log(job_id=1, status=0, job_name="测试任务"):
"""构造一条任务日志"""
log = JobLogRepository.create(
job_id=job_id, job_name=job_name, job_group="default",
job_trigger="cron", job_message="执行成功", cron_expression="0 0/5 * * * ?",
status=status,
start_time=datetime.now(), end_time=datetime.now(),
consume_time=120, create_user="system",
)
db.session.commit()
return log.id
class TestJobLogPage:
"""分页富化(statusText 走字典)与筛选"""
def test_page_with_status_text(self, app):
with app.app_context():
seed_dict(app)
_create_job_log(job_id=1, status=0, job_name="任务A")
_create_job_log(job_id=2, status=1, job_name="任务B")
data = JobLogService.get_page({"pageNo": 1, "pageSize": 10})
by_name = {r["jobName"]: r for r in data["records"]}
assert by_name["任务A"]["statusText"] == "正常"
assert by_name["任务B"]["statusText"] == "失败"
def test_page_filter_by_job_id_and_status(self, app):
with app.app_context():
_create_job_log(job_id=1, status=0, job_name="A")
_create_job_log(job_id=1, status=1, job_name="B")
_create_job_log(job_id=2, status=0, job_name="C")
data = JobLogService.get_page({"pageNo": 1, "pageSize": 10, "jobId": "1", "status": "0"})
assert [r["jobName"] for r in data["records"]] == ["A"]
class TestJobLogDelete:
"""软删除"""
def test_delete_soft(self, app):
with app.app_context():
log_id = _create_job_log()
count = JobLogService.remove(str(log_id))
assert count == 1
assert JobLogRepository.get_by_id(log_id) is None # 软删除后默认查询不可见
class TestWriteJobLog:
"""任务执行日志写入(job 调度器调用)"""
def test_write_success(self, app):
with app.app_context():
job = JobRepository.create(
job_name="写日志任务", job_group="default", job_trigger="cron",
cron_expression="0 0/5 * * * ?", status=1, create_user="system",
)
db.session.commit()
write_job_log(
job.id, success=True, message="执行成功",
start_time=datetime(2026, 1, 1, 10, 0, 0), end_time=datetime(2026, 1, 1, 10, 0, 2),
)
log = JobLogRepository.first_by({"job_id": job.id})
assert log is not None
assert log.status == JobLog.Status.NORMAL
assert log.job_name == "写日志任务"
assert log.job_group == "default"
assert log.cron_expression == "0 0/5 * * * ?"
assert log.consume_time == 2000 # 2 秒 = 2000ms
assert log.job_message == "执行成功"
def test_write_failed(self, app):
with app.app_context():
job = JobRepository.create(job_name="失败任务", status=1, create_user="system")
db.session.commit()
write_job_log(job.id, success=False, error="boom")
log = JobLogRepository.first_by({"job_id": job.id})
assert log.status == JobLog.Status.FAILED
assert log.exception_info == "boom"
def test_write_skips_when_job_missing(self, app):
with app.app_context():
write_job_log(99999, success=False, error="no job") # 任务不存在,不写日志
assert JobLogRepository.find_list() == []
@@ -0,0 +1,49 @@
"""
定时任务服务测试(F4 调度器入参修复)
=============================================================
验证 JobService 与调度器的联动契约:
- add_job_to_scheduler 应收到 dict(驼峰键,含 cronExpression/id/status),
而非 ORM 实例(原实现传 ORM 实例 → AttributeError 被吞 → 任务永不注册)
"""
from src.core.database import db
from src.modules.job.service import JobService
def _job_payload():
"""构造合法任务表单数据(isSync=0 异步:回归验证 DataRequired→Optional 修复)"""
return {
"jobName": "test_job",
"status": 1,
"executePolicy": 1,
"isSync": 0,
"cronExpression": "0 2 * * *",
"url": "http://localhost/api/v1/notify",
"note": "测试",
}
class TestJobSchedulerDict:
"""调度器入参应为驼峰 dict"""
def test_add_passes_dict_to_scheduler(self, app, monkeypatch):
captured = {}
def fake_add(job_dict):
captured["arg"] = job_dict
return True
monkeypatch.setattr("src.modules.job.service.add_job_to_scheduler", fake_add)
with app.app_context():
JobService.add(_job_payload())
db.session.commit()
assert "arg" in captured
arg = captured["arg"]
# 必须是 dict 且含调度器所需驼峰键(原 ORM 实例无 .get → AttributeError)
assert isinstance(arg, dict)
assert arg["cronExpression"] == "0 2 * * *"
assert arg["status"] == 1
assert arg["id"] is not None
@@ -0,0 +1,213 @@
"""
职级服务单元测试(Excel 导入/导出复用 excel_utils)
=============================================================
验证:
- export_levels 支持与分页一致的筛选参数(name 模糊、status 精确)
- 无筛选时导出全部记录;表头与数据列对齐
- import_levels 上传/解析/入库/清理(临时文件生命周期)
- 上传失败与文件缺失时透传错误
文件落盘通过 monkeypatch excel_utils.save_export_file 跳过,
仅验证查询、列构建与写盘调用契约。
"""
import pytest
from src.common.utils import excel_utils
from src.core.exceptions import BizError
from src.modules.system.level import service as level_service
from src.modules.system.level.repository import LevelRepository
@pytest.fixture
def seed_levels(app):
"""插入职级测试数据(sqlite 内存库)"""
from src.core.database import db
with app.app_context():
LevelRepository.create(name="初级工程师", status=1, sort=1)
LevelRepository.create(name="中级工程师", status=1, sort=2)
LevelRepository.create(name="高级工程师", status=2, sort=3)
LevelRepository.create(name="产品经理", status=1, sort=4)
db.session.commit()
def _run_export(app, monkeypatch, params):
"""调用导出并捕获表头/行数据(跳过文件落盘,仅验证查询与列构建)"""
captured = {}
def fake_save(headers, rows, filename_prefix="export"):
captured["headers"] = headers
captured["rows"] = rows
return "/static/temp/level_xxx.xlsx"
monkeypatch.setattr(excel_utils, "save_export_file", fake_save)
with app.app_context():
result = level_service.export_levels(params)
url = result.get_json()["data"]
return captured, url
class TestExportLevels:
"""Excel 导出:筛选参数 + 列对齐"""
HEADER = ["职级ID", "职级名称", "职级排序", "职级状态", "创建时间", "更新时间"]
def test_export_no_filter_exports_all(self, app, monkeypatch, seed_levels):
captured, url = _run_export(app, monkeypatch, None)
assert captured["headers"] == self.HEADER
assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师", "高级工程师", "产品经理"]
assert url.startswith("/static")
def test_export_filters_by_name(self, app, monkeypatch, seed_levels):
captured, _ = _run_export(app, monkeypatch, {"name": "工程师"})
assert captured["headers"] == self.HEADER
assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师", "高级工程师"]
def test_export_filters_by_status(self, app, monkeypatch, seed_levels):
captured, _ = _run_export(app, monkeypatch, {"status": 2})
assert [r[1] for r in captured["rows"]] == ["高级工程师"]
def test_export_filters_by_name_and_status(self, app, monkeypatch, seed_levels):
captured, _ = _run_export(app, monkeypatch, {"name": "工程师", "status": 1})
assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师"]
def test_export_columns_aligned(self, app, monkeypatch, seed_levels):
captured, _ = _run_export(app, monkeypatch, {"name": "产品经理"})
assert captured["headers"] == self.HEADER
row = captured["rows"][0]
assert row[0] is not None # 职级ID
assert row[1] == "产品经理" # 职级名称
assert row[2] == 4 # 职级排序
assert row[3] == "正常" # 职级状态(formatter 展示为中文)
assert row[4] # 创建时间
assert row[5] # 更新时间
def test_export_empty_result_keeps_header(self, app, monkeypatch, seed_levels):
captured, _ = _run_export(app, monkeypatch, {"name": "不存在的职级"})
assert captured["headers"] == self.HEADER
assert captured["rows"] == []
class TestLevelList:
"""职级列表(无分页):参数条件筛选 + status=1 硬性过滤"""
def test_list_no_filter_returns_enabled_only(self, app, seed_levels):
with app.app_context():
data = level_service.get_level_list(None)
assert [d["name"] for d in data] == ["初级工程师", "中级工程师", "产品经理"]
def test_list_filters_by_name(self, app, seed_levels):
with app.app_context():
data = level_service.get_level_list({"name": "工程师"})
assert [d["name"] for d in data] == ["初级工程师", "中级工程师"]
def test_list_status_param_is_ignored(self, app, seed_levels):
# status 为硬性条件,即使传入 status=2 也只返回启用(status=1)记录
with app.app_context():
data = level_service.get_level_list({"name": "工程师", "status": 2})
assert [d["name"] for d in data] == ["初级工程师", "中级工程师"]
class TestImportLevels:
"""职级Excel导入(复用 excel_utils 上传/解析/清理机制)"""
def _patch_upload(self, monkeypatch, tmp_path, file_name="levels.xlsx"):
"""将上传模拟指向 tmp_path/temp/20260817 目录,返回该文件路径"""
target_dir = tmp_path / "temp" / "20260817"
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / file_name
monkeypatch.setattr(
excel_utils.upload, "upload_file",
lambda *a, **k: {"filePath": "/temp/20260817/" + file_name},
)
monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", str(tmp_path))
return target
def test_import_success(self, app, monkeypatch, tmp_path):
import shutil
from openpyxl.workbook import Workbook
from src.modules.system.level.models import Level
# 构造真实 xlsx 并放入模拟的上传目录(temp/日期/文件)
src = tmp_path / "levels.xlsx"
wb = Workbook()
ws = wb.worksheets[0]
ws.append(["职级ID", "职级名称", "职级排序", "职级状态"])
ws.append([None, "初级", 1, "正常"])
ws.append([None, "停用级", 2, "禁用"])
ws.append([None, None, None, None]) # 整行为空 → 跳过
wb.save(src)
target = self._patch_upload(monkeypatch, tmp_path)
shutil.copy(src, target)
with app.app_context():
result = level_service.import_levels()
rows = Level.query.order_by(Level.sort).all()
assert result.get_json()["code"] == 0
assert [r.name for r in rows] == ["初级", "停用级"]
assert rows[0].status == 1 # "正常" → 1
assert rows[1].status == 2 # "禁用" → 2
assert not target.exists() # 临时导入文件已清理
def test_import_required_missing_fails_whole_import(self, app, monkeypatch, tmp_path):
import shutil
from openpyxl.workbook import Workbook
from src.modules.system.level.models import Level
# 名称必填缺失 → 行级错误 → 整体失败,无任何数据入库
src = tmp_path / "levels.xlsx"
wb = Workbook()
ws = wb.worksheets[0]
ws.append(["职级ID", "职级名称", "职级排序", "职级状态"])
ws.append([None, "", 1, "正常"])
wb.save(src)
target = self._patch_upload(monkeypatch, tmp_path)
shutil.copy(src, target)
with app.app_context():
result = level_service.import_levels()
count = Level.query.count()
body = result.get_json()
assert body["code"] == 1
assert "职级名称不能为空" in body["msg"]
assert count == 0
def test_import_upload_failure_propagates(self, app, monkeypatch):
# 上传失败由 upload_file 抛 BizError,import_levels 捕获后透传具体错误信息
def _fail_upload(*args, **kwargs):
raise BizError("文件格式不支持,仅支持 .xls, .xlsx 格式")
monkeypatch.setattr(excel_utils.upload, "upload_file", _fail_upload)
with app.app_context():
result = level_service.import_levels()
body = result.get_json()
assert body["code"] == 1
assert "文件格式不支持" in body["msg"]
def test_import_file_missing_returns_failed(self, app, monkeypatch):
monkeypatch.setattr(
excel_utils.upload, "upload_file",
lambda *a, **k: {"filePath": "/temp/20260817/nonexist.xlsx"},
)
monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", "nonexistent_dir")
with app.app_context():
result = level_service.import_levels()
body = result.get_json()
assert body["code"] == 1
assert "导入文件不存在" in body["msg"]
@@ -10,6 +10,7 @@
import pytest
from src.common.utils.model_utils import build_page_data
from src.core.exceptions import BizError
from src.modules.system.position import service as position_service
@@ -18,7 +19,7 @@ class TestBuildPageData:
"""分页数据构建(纯函数)"""
def test_shape(self):
data = position_service._build_page_data([], 0, 10, 1, 0)
data = build_page_data([], 0, 1, 10)
assert data == {'records': [], 'total': 0, 'size': 10, 'current': 1, 'pages': 0}
@@ -63,9 +64,9 @@ class TestDeletePositions:
with pytest.raises(BizError, match="无效的记录ID"):
position_service.delete_positions('abc')
def test_parse_and_delete(self, monkeypatch):
"""ID 解析后调用模型批量删除,返回删除计数"""
from src.modules.system.position.models import Position
def test_parse_and_delete(self, app, monkeypatch):
"""ID 解析后调用仓库批量删除,返回删除计数(before_delete 引用校验需 DB 上下文)"""
from src.modules.system.position.repository import PositionRepository
captured = {}
@@ -73,9 +74,41 @@ class TestDeletePositions:
captured['ids'] = ids
return len(ids)
monkeypatch.setattr(Position, 'batch_delete', classmethod(fake_batch_delete))
monkeypatch.setattr(PositionRepository, 'batch_delete', classmethod(fake_batch_delete))
count = position_service.delete_positions('1,2,3')
with app.app_context():
count = position_service.delete_positions('1,2,3')
assert count == 3
assert captured['ids'] == [1, 2, 3]
class TestPositionList:
"""岗位列表(无分页):参数条件筛选 + status=1 硬性过滤"""
@pytest.fixture
def seed_positions(self, app):
from src.core.database import db
from src.modules.system.position.repository import PositionRepository
with app.app_context():
PositionRepository.create(name="产品经理", status=1, sort=1)
PositionRepository.create(name="项目经理", status=1, sort=2)
PositionRepository.create(name="测试主管", status=0, sort=3)
db.session.commit()
def test_list_no_filter_returns_enabled_only(self, app, seed_positions):
with app.app_context():
data = position_service.get_position_list(None)
assert [d["name"] for d in data] == ["产品经理", "项目经理"]
def test_list_filters_by_name(self, app, seed_positions):
with app.app_context():
data = position_service.get_position_list({"name": "经理"})
assert [d["name"] for d in data] == ["产品经理", "项目经理"]
def test_list_status_param_is_ignored(self, app, seed_positions):
# status 为硬性条件,即使传入 status=0 也只返回启用(status=1)记录
with app.app_context():
data = position_service.get_position_list({"name": "经理", "status": 0})
assert [d["name"] for d in data] == ["产品经理", "项目经理"]
@@ -0,0 +1,39 @@
"""
角色服务单元测试(列表查询)
=============================================================
验证:
- get_role_list 支持与分页一致的 name 模糊筛选
- 无筛选时返回全部角色(角色无 status 字段)
"""
import pytest
from src.modules.system.role import service as role_service
from src.modules.system.role.repository import RoleRepository
@pytest.fixture
def seed_roles(app):
"""插入角色测试数据(sqlite 内存库)"""
from src.core.database import db
with app.app_context():
RoleRepository.create(name="超级管理员", code="super", sort=1)
RoleRepository.create(name="系统管理员", code="admin", sort=2)
RoleRepository.create(name="普通用户", code="user", sort=3)
db.session.commit()
class TestRoleList:
"""角色列表(无分页):参数条件筛选"""
def test_list_no_filter_returns_all(self, app, seed_roles):
with app.app_context():
data = role_service.get_role_list(None)
assert [d["name"] for d in data] == ["超级管理员", "系统管理员", "普通用户"]
def test_list_filters_by_name(self, app, seed_roles):
with app.app_context():
data = role_service.get_role_list({"name": "管理员"})
assert [d["name"] for d in data] == ["超级管理员", "系统管理员"]
+55
View File
@@ -0,0 +1,55 @@
"""
字典一键初始化脚本测试(seed_dict)
=============================================================
验证 scripts/seed_dict.py 的幂等初始化:
- 首次执行创建 DICT_SEED 中全部字典(dict + dict_item)
- 二次执行不重复(按 code 跳过已存在)
- 种入后 dict_utils 可从字典表取值
"""
import pytest
from src.modules.dictionary import dict_utils
from src.modules.dictionary.dict.repository import DictRepository
from src.modules.dictionary.dict_item.repository import DictItemRepository
from scripts.seed_dict import seed_dict
@pytest.fixture(autouse=True)
def _clear_dict_cache():
"""每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存"""
dict_utils.clear_cache()
yield
dict_utils.clear_cache()
class TestSeedDict:
def test_seed_creates_all_dicts(self, app):
from scripts.seed_dict import DICT_SEED
with app.app_context():
created = seed_dict(app)
assert created == len(DICT_SEED)
for spec in DICT_SEED:
dict_obj = DictRepository.first_by({"code": spec["code"]})
assert dict_obj is not None, spec["code"]
assert len(DictItemRepository.list_by_code(spec["code"])) == len(spec["items"])
def test_seed_idempotent(self, app):
with app.app_context():
seed_dict(app)
first_items = len(DictItemRepository.list_by_code("gender"))
created = seed_dict(app)
assert created == 0
# 重跑不重复:item 数不变、dict 仍只有一份
assert len(DictItemRepository.list_by_code("gender")) == first_items
assert len(DictRepository.find_list(filters={"code": "gender"})) == 1
def test_seed_works_with_dict_utils(self, app):
with app.app_context():
seed_dict(app)
assert dict_utils.get_name("gender", 1) == "男"
assert dict_utils.get_name("operation_type", 3) == "新增"
assert dict_utils.get_name("article_status", 0) == "正常"
assert dict_utils.get_name("operation_source", "0") == "后台系统"
@@ -0,0 +1,384 @@
"""
用户服务单元测试(U3 用户模块接入 BaseService)
=============================================================
验证用户模块重构后的关键契约:
- 新增用户不再触发 salt AttributeError(原 form.salt.data 崩溃点)
- 密码加密 + salt 写入、行政区划拆分到蛇形列
- 更新时不传密码保留原密码;传密码则重新加密
- 分页参数统一 pageNo/pageSize(对齐 BaseService.get_page)
"""
import pytest
from src.common.utils import excel_utils
from src.core.database import db
from src.core.exceptions import BizError
from src.modules.system.user import service as user_service
from src.modules.system.user.repository import UserRepository
from src.modules.system.user.service import UserService
def _payload(username="testuser", password="123456"):
"""构造一份合法的用户新增表单数据"""
return {
"username": username,
"realname": "测试用户",
"gender": 1,
"avatar": "avatar.png",
"mobile": "13800000000",
"email": "test@test.com",
"deptId": 1,
"levelId": 1,
"positionId": 1,
"city": ["440000", "440100", "440103", "440106"],
"address": "测试路1号",
"status": 1,
"sort": 1,
"password": password,
}
def _create_user(app, username="base_user"):
"""经 UserService.add 新增一个用户并返回其ID"""
with app.app_context():
UserService.add(_payload(username=username))
db.session.commit()
user = UserRepository.first_by({"username": username})
return user.id
class TestUserAdd:
"""新增用户(salt 崩溃点回归)"""
def test_add_creates_with_encrypted_password_and_city(self, app):
with app.app_context():
UserService.add(_payload())
db.session.commit()
user = UserRepository.first_by({"username": "testuser"})
assert user is not None
# 密码已加密且 salt 已写入(原实现此处 AttributeError)
assert user.password != "123456"
assert user.salt
# 行政区划拆分到蛇形列
assert user.province_code == "440000"
assert user.city_code == "440100"
assert user.district_code == "440103"
assert user.street_code == "440106"
def test_add_invalid_city_raises(self, app):
with app.app_context():
with pytest.raises(BizError, match="请选择完整的所在城市"):
data = _payload()
data["city"] = ["440000"]
UserService.add(data)
def test_add_generates_six_digit_code_from_id(self, app):
with app.app_context():
UserService.add(_payload())
db.session.commit()
user = UserRepository.first_by({"username": "testuser"})
# 编码 = ID 补零为 6 位(如 id=10 -> 000010)
assert user.code == f"{user.id:06d}"
assert len(user.code) == 6
assert user.code.isdigit()
class TestUserUpdate:
"""更新用户(密码保留/重设)"""
def test_update_without_password_keeps_original(self, app):
user_id = _create_user(app)
with app.app_context():
data = _payload(username="base_user")
data["id"] = user_id
data.pop("password") # 不传新密码
UserService.update(data)
db.session.commit()
db.session.expire_all()
user = UserRepository.get_by_id(user_id)
assert user.password != "123456"
assert user.realname == "测试用户"
def test_update_with_new_password_reencrypts(self, app):
user_id = _create_user(app)
with app.app_context():
data = _payload(username="base_user", password="newpass888")
data["id"] = user_id
UserService.update(data)
db.session.commit()
db.session.expire_all()
user = UserRepository.get_by_id(user_id)
assert user.password != "123456"
assert user.password != "newpass888"
assert user.salt
class TestReferenceCheck:
"""删除前用户引用校验(C4 防孤儿引用)"""
def test_position_delete_blocked_when_referenced(self, app):
from src.modules.system.position.repository import PositionRepository
from src.modules.system.position.service import PositionService
with app.app_context():
pos = PositionRepository.create(name='经理岗', status=1, sort=0)
db.session.commit()
pid = pos.id
with app.app_context():
data = _payload(username='ref_user')
data['positionId'] = pid
UserService.add(data)
db.session.commit()
with app.app_context():
with pytest.raises(BizError, match="已被用户引用"):
PositionService.remove(str(pid))
def test_position_delete_unreferenced_succeeds(self, app):
from src.modules.system.position.repository import PositionRepository
from src.modules.system.position.service import PositionService
with app.app_context():
pos = PositionRepository.create(name='临时岗', status=1, sort=0)
db.session.commit()
pid = pos.id
with app.app_context():
count = PositionService.remove(str(pid))
assert count == 1
class TestUserZeroIds:
"""deptId/levelId/positionId 允许 0(无部门/无职级/无岗位)"""
def test_add_with_zero_ids(self, app):
with app.app_context():
data = _payload(username="zero_ids")
data["deptId"] = 0
data["levelId"] = 0
data["positionId"] = 0
UserService.add(data)
db.session.commit()
user = UserRepository.first_by({"username": "zero_ids"})
assert user is not None
assert user.dept_id == 0
assert user.level_id == 0
assert user.position_id == 0
class TestUserPage:
"""分页参数统一 pageNo/pageSize"""
def test_page_uses_pageNo_pageSize(self, app):
_create_user(app, "user_a")
_create_user(app, "user_b")
_create_user(app, "user_c")
with app.app_context():
data = UserService.get_page({"pageNo": 2, "pageSize": 2})
# 3 条数据、每页 2 条 → 第 2 页 1 条
assert data["total"] == 3
assert data["size"] == 2
assert data["current"] == 2
assert data["pages"] == 2
assert len(data["records"]) == 1
class TestUserImportExport:
"""用户Excel导入/导出(复用 excel_utils)"""
@pytest.fixture(autouse=True)
def _clear_dict_cache(self):
from src.modules.dictionary import dict_utils
dict_utils.clear_cache()
yield
dict_utils.clear_cache()
def _seed_refs(self, app):
"""构造部门/职级/岗位/角色 + 性别字典,返回各 ID(避免跨上下文传模型实例)"""
from src.modules.dictionary.dict_item.repository import DictItemRepository
from src.modules.dictionary.dict.repository import DictRepository
from src.modules.system.dept.repository import DeptRepository
from src.modules.system.level.repository import LevelRepository
from src.modules.system.position.repository import PositionRepository
from src.modules.system.role.repository import RoleRepository
with app.app_context():
dept = DeptRepository.create(name="研发部", status=1, sort=1)
level = LevelRepository.create(name="高级工程师", status=1, sort=1)
pos = PositionRepository.create(name="后端工程师", status=1, sort=1)
role = RoleRepository.create(name="普通用户", code="normal", status=1, sort=1)
dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system")
for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]:
DictItemRepository.create(
dict_id=dict_obj.id, name=name, value=value,
sort=sort, create_user="system",
)
db.session.commit()
return dept.id, level.id, pos.id, role.id
def _create_user_with_refs(self, app, dept_id, level_id, pos_id, role_id, username="exp_user", realname="导入导出"):
"""经 UserService.add 创建含部门/职级/岗位/角色的用户,用于导出测试"""
with app.app_context():
data = _payload(username=username)
data.update({
"realname": realname,
"deptId": dept_id,
"levelId": level_id,
"positionId": pos_id,
"roles": [role_id],
})
UserService.add(data)
db.session.commit()
def _patch_upload(self, monkeypatch, tmp_path, file_name="users.xlsx"):
"""将上传模拟指向 tmp_path/temp/20260817 目录,返回该文件路径"""
target_dir = tmp_path / "temp" / "20260817"
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / file_name
monkeypatch.setattr(
excel_utils.upload, "upload_file",
lambda *a, **k: {"filePath": "/temp/20260817/" + file_name},
)
monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", str(tmp_path))
return target
def _run_export(self, app, monkeypatch, params):
"""调用导出并捕获表头/行数据(跳过文件落盘)"""
captured = {}
def fake_save(headers, rows, filename_prefix="export"):
captured["headers"] = headers
captured["rows"] = rows
return "/static/temp/user_xxx.xlsx"
monkeypatch.setattr(excel_utils, "save_export_file", fake_save)
with app.app_context():
result = user_service.export_users(params)
url = result.get_json()["data"]
return captured, url
# ============================================================
# 导出
# ============================================================
def test_export_includes_enriched_names_and_url(self, app, monkeypatch):
dept_id, level_id, pos_id, role_id = self._seed_refs(app)
self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "exp_user")
captured, url = self._run_export(app, monkeypatch, None)
assert url.startswith("/static")
# 表头为导出列定义(含富化后的展示列)
assert captured["headers"] == [
"用户编码", "登录账号", "用户姓名", "性别", "手机号", "邮箱",
"部门", "职级", "岗位", "用户角色", "状态", "详细地址", "创建时间",
]
row = captured["rows"][0]
assert len(row[0]) == 6 # 用户编码补零 6 位
assert row[1] == "exp_user"
assert row[2] == "导入导出"
assert row[3] == "男" # 性别走字典
assert row[6] == "研发部" # 部门名称
assert row[7] == "高级工程师" # 职级名称
assert row[8] == "后端工程师" # 岗位名称
assert row[9] == "普通用户" # 角色中文名
assert row[10] == "正常" # 状态展示为中文
def test_export_filters_by_realname(self, app, monkeypatch):
dept_id, level_id, pos_id, role_id = self._seed_refs(app)
self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "exp_user", realname="张三")
self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "other_user", realname="李四")
captured, _ = self._run_export(app, monkeypatch, {"realname": "张"})
assert [r[1] for r in captured["rows"]] == ["exp_user"]
# ============================================================
# 导入
# ============================================================
def test_import_creates_users_with_resolved_refs(self, app, monkeypatch, tmp_path):
import shutil
from openpyxl.workbook import Workbook
from src.modules.association.user_role.repository import UserRoleRepository
dept_id, level_id, pos_id, role_id = self._seed_refs(app)
# 构造真实 xlsx:部门/职级/岗位/角色按名称匹配,密码缺省用默认密码
src = tmp_path / "users.xlsx"
wb = Workbook()
ws = wb.worksheets[0]
ws.append(["用户ID", "登录账号", "用户姓名", "性别", "手机号", "邮箱",
"部门名称", "职级名称", "岗位名称", "用户角色", "状态", "排序",
"详细地址", "个人简介", "备注", "初始密码"])
ws.append([None, "imp_user", "导入用户", "男", "13900000000", "imp@test.com",
"研发部", "高级工程师", "后端工程师", "普通用户", "正常", 1,
"测试路3号", "简介", "备注", ""])
wb.save(src)
target = self._patch_upload(monkeypatch, tmp_path)
shutil.copy(src, target)
with app.app_context():
result = user_service.import_users()
db.session.commit()
user = UserRepository.first_by({"username": "imp_user"})
assert result.get_json()["code"] == 0
assert user is not None
# 编码按 ID 补零 6 位(与 UserService.add 一致)
assert user.code == f"{user.id:06d}"
# 部门/职级/岗位按名称解析为 ID
assert user.dept_id == dept_id
assert user.level_id == level_id
assert user.position_id == pos_id
# 角色按名称关联
rels = UserRoleRepository.find_list({"user_id": user.id})
assert [r.role_id for r in rels] == [role_id]
# 密码缺省为默认密码并已加密
assert user.password != "123456"
assert user.salt
assert not target.exists() # 临时导入文件已清理
def test_import_duplicate_username_fails(self, app, monkeypatch, tmp_path):
import shutil
from openpyxl.workbook import Workbook
with app.app_context():
UserService.add(_payload(username="dup_user"))
db.session.commit()
src = tmp_path / "users.xlsx"
wb = Workbook()
ws = wb.worksheets[0]
ws.append(["用户ID", "登录账号", "用户姓名", "性别", "手机号", "邮箱",
"部门名称", "职级名称", "岗位名称", "用户角色", "状态", "排序",
"详细地址", "个人简介", "备注", "初始密码"])
ws.append([None, "dup_user", "导入用户", "男", "13900000000", "imp@test.com",
"", "", "", "", "正常", 1, "测试路3号", "", "", ""])
wb.save(src)
target = self._patch_upload(monkeypatch, tmp_path)
shutil.copy(src, target)
with app.app_context():
result = user_service.import_users()
body = result.get_json()
assert body["code"] == 1
assert "dup_user" in body["msg"]
assert "已存在" in body["msg"]