签入版本
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user