签入版本
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""业务模块服务单元测试。"""
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
岗位服务单元测试(P4 服务层改造标杆)
|
||||
=============================================================
|
||||
|
||||
验证 P4 改造后的服务层契约:
|
||||
- 服务函数「入参数据、出参数据」
|
||||
- 业务失败通过 raise BizError 表达(不再 return R.failed)
|
||||
- 纯函数与可 mock 路径无需数据库即可测试
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestBuildPageData:
|
||||
"""分页数据构建(纯函数)"""
|
||||
|
||||
def test_shape(self):
|
||||
data = build_page_data([], 0, 1, 10)
|
||||
assert data == {'records': [], 'total': 0, 'size': 10, 'current': 1, 'pages': 0}
|
||||
|
||||
|
||||
class TestAddPosition:
|
||||
"""添加岗位"""
|
||||
|
||||
def test_empty_data_raises_bizerror(self, flask_app):
|
||||
"""空数据触发表单验证失败 → BizError(FlaskForm 需应用上下文)"""
|
||||
with flask_app.app_context():
|
||||
with pytest.raises(BizError):
|
||||
position_service.add_position({})
|
||||
|
||||
|
||||
class TestUpdatePosition:
|
||||
"""更新岗位"""
|
||||
|
||||
def test_empty_data_raises_bizerror(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
with pytest.raises(BizError):
|
||||
position_service.update_position({})
|
||||
|
||||
|
||||
class TestUpdateStatus:
|
||||
"""状态设置"""
|
||||
|
||||
def test_empty_data_raises_bizerror(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
with pytest.raises(BizError):
|
||||
position_service.update_position_status({})
|
||||
|
||||
|
||||
class TestDeletePositions:
|
||||
"""批量删除"""
|
||||
|
||||
def test_empty_raises(self):
|
||||
"""空 ID 触发 BizError"""
|
||||
with pytest.raises(BizError, match="记录ID不存在"):
|
||||
position_service.delete_positions('')
|
||||
|
||||
def test_invalid_ids_raises(self):
|
||||
"""非数字 ID 解析为空 → BizError"""
|
||||
with pytest.raises(BizError, match="无效的记录ID"):
|
||||
position_service.delete_positions('abc')
|
||||
|
||||
def test_parse_and_delete(self, app, monkeypatch):
|
||||
"""ID 解析后调用仓库批量删除,返回删除计数(before_delete 引用校验需 DB 上下文)"""
|
||||
from src.modules.system.position.repository import PositionRepository
|
||||
|
||||
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 = 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] == ["超级管理员", "系统管理员"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user