feat(phase0): 工具链 + 契约快照测试,修复既有 bug
- 新增 pyproject.toml / pytest.ini / Makefile / requirements-dev.txt - 新增 3 个契约快照测试(路由167/权限串148/表名25),守护前端与数据契约 - 修复 requirements.txt UTF-16 编码导致 pip 无法解析 - 修复 JobLog 未继承 Base 导致无法映射(fastapi_job_log 表缺失于 metadata) - 修复 base_model.to_dict() 未序列化纯 date 类型(birthday 等导致 500) - 补充 .gitignore(.env/缓存/上传目录) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# DjangoAdmin FastAPI+EleVue 常用命令
|
||||
# Windows 无 make 时可用:python -m pytest / python main.py 等直接执行
|
||||
|
||||
.PHONY: install dev test test-contract test-unit test-integration lint
|
||||
|
||||
# 安装依赖(运行 + 开发)
|
||||
install:
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# 启动开发服务
|
||||
dev:
|
||||
python main.py
|
||||
|
||||
# 全部测试(默认跳过 integration)
|
||||
test:
|
||||
pytest
|
||||
|
||||
# 契约快照测试
|
||||
test-contract:
|
||||
pytest tests/contract -m contract
|
||||
|
||||
# 单元测试
|
||||
test-unit:
|
||||
pytest tests/unit
|
||||
|
||||
# 集成测试(需真实 MySQL/Redis)
|
||||
test-integration:
|
||||
pytest tests/integration -m integration
|
||||
|
||||
# 静态检查(需 pip install ruff)
|
||||
lint:
|
||||
ruff check .
|
||||
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "djangoadmin-fastapi-elevue"
|
||||
version = "2.8.0"
|
||||
description = "DjangoAdmin FastAPI+EleVue 企业数字化开发平台"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
minversion = 7.0
|
||||
addopts = -ra --strict-markers
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
markers =
|
||||
contract: 契约快照测试(路由/权限串/表名),守护前端与数据契约
|
||||
integration: 需要真实 MySQL/Redis 的集成测试,默认跳过
|
||||
@@ -0,0 +1,6 @@
|
||||
# 开发/测试依赖
|
||||
pytest>=7.4
|
||||
pytest-asyncio>=0.23
|
||||
httpx>=0.27
|
||||
aiosqlite>=0.19
|
||||
fakeredis>=2.20
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
"""pytest 全局配置:确保项目根目录在 sys.path。"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
@@ -0,0 +1,150 @@
|
||||
[
|
||||
"sys:ad:add",
|
||||
"sys:ad:batch_delete",
|
||||
"sys:ad:delete",
|
||||
"sys:ad:detail",
|
||||
"sys:ad:page",
|
||||
"sys:ad:status",
|
||||
"sys:ad:update",
|
||||
"sys:adsort:add",
|
||||
"sys:adsort:batch_delete",
|
||||
"sys:adsort:delete",
|
||||
"sys:adsort:detail",
|
||||
"sys:adsort:page",
|
||||
"sys:adsort:update",
|
||||
"sys:city:add",
|
||||
"sys:city:batch_delete",
|
||||
"sys:city:delete",
|
||||
"sys:city:detail",
|
||||
"sys:city:page",
|
||||
"sys:city:update",
|
||||
"sys:config:add",
|
||||
"sys:config:batch_delete",
|
||||
"sys:config:delete",
|
||||
"sys:config:detail",
|
||||
"sys:config:page",
|
||||
"sys:config:update",
|
||||
"sys:configitem:add",
|
||||
"sys:configitem:batch_delete",
|
||||
"sys:configitem:delete",
|
||||
"sys:configitem:detail",
|
||||
"sys:configitem:page",
|
||||
"sys:configitem:status",
|
||||
"sys:configitem:update",
|
||||
"sys:dept:add",
|
||||
"sys:dept:delete",
|
||||
"sys:dept:detail",
|
||||
"sys:dept:list",
|
||||
"sys:dept:update",
|
||||
"sys:dict:add",
|
||||
"sys:dict:batch_delete",
|
||||
"sys:dict:delete",
|
||||
"sys:dict:detail",
|
||||
"sys:dict:page",
|
||||
"sys:dict:update",
|
||||
"sys:dictitem:add",
|
||||
"sys:dictitem:batch_delete",
|
||||
"sys:dictitem:delete",
|
||||
"sys:dictitem:detail",
|
||||
"sys:dictitem:page",
|
||||
"sys:dictitem:status",
|
||||
"sys:dictitem:update",
|
||||
"sys:item:add",
|
||||
"sys:item:batch_delete",
|
||||
"sys:item:delete",
|
||||
"sys:item:detail",
|
||||
"sys:item:page",
|
||||
"sys:item:update",
|
||||
"sys:itemcate:add",
|
||||
"sys:itemcate:batch_delete",
|
||||
"sys:itemcate:delete",
|
||||
"sys:itemcate:detail",
|
||||
"sys:itemcate:page",
|
||||
"sys:itemcate:update",
|
||||
"sys:job:add",
|
||||
"sys:job:delete",
|
||||
"sys:job:detail",
|
||||
"sys:job:page",
|
||||
"sys:job:pause",
|
||||
"sys:job:reload",
|
||||
"sys:job:resume",
|
||||
"sys:job:run_once",
|
||||
"sys:job:status",
|
||||
"sys:job:update",
|
||||
"sys:job_log:batch_delete",
|
||||
"sys:job_log:delete",
|
||||
"sys:job_log:detail",
|
||||
"sys:job_log:page",
|
||||
"sys:level:add",
|
||||
"sys:level:batch_delete",
|
||||
"sys:level:delete",
|
||||
"sys:level:detail",
|
||||
"sys:level:export",
|
||||
"sys:level:import",
|
||||
"sys:level:page",
|
||||
"sys:level:status",
|
||||
"sys:level:update",
|
||||
"sys:link:add",
|
||||
"sys:link:batch_delete",
|
||||
"sys:link:delete",
|
||||
"sys:link:detail",
|
||||
"sys:link:page",
|
||||
"sys:link:status",
|
||||
"sys:link:update",
|
||||
"sys:login_log:batch_delete",
|
||||
"sys:login_log:delete",
|
||||
"sys:login_log:page",
|
||||
"sys:member:add",
|
||||
"sys:member:batch_delete",
|
||||
"sys:member:delete",
|
||||
"sys:member:detail",
|
||||
"sys:member:page",
|
||||
"sys:member:status",
|
||||
"sys:member:update",
|
||||
"sys:memberlevel:add",
|
||||
"sys:memberlevel:batch_delete",
|
||||
"sys:memberlevel:delete",
|
||||
"sys:memberlevel:detail",
|
||||
"sys:memberlevel:page",
|
||||
"sys:memberlevel:update",
|
||||
"sys:menu:add",
|
||||
"sys:menu:delete",
|
||||
"sys:menu:detail",
|
||||
"sys:menu:list",
|
||||
"sys:menu:update",
|
||||
"sys:notice:add",
|
||||
"sys:notice:batch_delete",
|
||||
"sys:notice:delete",
|
||||
"sys:notice:detail",
|
||||
"sys:notice:page",
|
||||
"sys:notice:update",
|
||||
"sys:operation_log:batch_delete",
|
||||
"sys:operation_log:delete",
|
||||
"sys:operation_log:page",
|
||||
"sys:position:add",
|
||||
"sys:position:batch_delete",
|
||||
"sys:position:delete",
|
||||
"sys:position:detail",
|
||||
"sys:position:page",
|
||||
"sys:position:status",
|
||||
"sys:position:update",
|
||||
"sys:role:add",
|
||||
"sys:role:batch_delete",
|
||||
"sys:role:delete",
|
||||
"sys:role:detail",
|
||||
"sys:role:page",
|
||||
"sys:role:status",
|
||||
"sys:role:update",
|
||||
"sys:rolemenu:index",
|
||||
"sys:rolemenu:save",
|
||||
"sys:upload:file",
|
||||
"sys:upload:image",
|
||||
"sys:user:add",
|
||||
"sys:user:batch_delete",
|
||||
"sys:user:delete",
|
||||
"sys:user:detail",
|
||||
"sys:user:page",
|
||||
"sys:user:resetPwd",
|
||||
"sys:user:status",
|
||||
"sys:user:update"
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
[
|
||||
"DELETE /ad/batchDelete",
|
||||
"DELETE /ad/delete/{id}",
|
||||
"DELETE /adsort/batchDelete",
|
||||
"DELETE /adsort/delete/{id}",
|
||||
"DELETE /city/batchDelete",
|
||||
"DELETE /city/delete/{id}",
|
||||
"DELETE /config/batchDelete",
|
||||
"DELETE /config/delete/{id}",
|
||||
"DELETE /configitem/batchDelete",
|
||||
"DELETE /configitem/delete/{id}",
|
||||
"DELETE /dept/delete/{id}",
|
||||
"DELETE /dict/batchDelete",
|
||||
"DELETE /dict/delete/{id}",
|
||||
"DELETE /dictitem/batchDelete",
|
||||
"DELETE /dictitem/delete/{id}",
|
||||
"DELETE /item/batchDelete",
|
||||
"DELETE /item/delete/{id}",
|
||||
"DELETE /itemcate/batchDelete",
|
||||
"DELETE /itemcate/delete/{id}",
|
||||
"DELETE /job/delete/{id}",
|
||||
"DELETE /joblog/batchDelete",
|
||||
"DELETE /joblog/delete/{id}",
|
||||
"DELETE /level/batchDelete",
|
||||
"DELETE /level/delete/{id}",
|
||||
"DELETE /link/batchDelete",
|
||||
"DELETE /link/delete/{id}",
|
||||
"DELETE /loginlog/batchDelete",
|
||||
"DELETE /loginlog/delete/{id}",
|
||||
"DELETE /member/batchDelete",
|
||||
"DELETE /member/delete/{id}",
|
||||
"DELETE /memberlevel/batchDelete",
|
||||
"DELETE /memberlevel/delete/{id}",
|
||||
"DELETE /menu/delete/{id}",
|
||||
"DELETE /notice/batchDelete",
|
||||
"DELETE /notice/delete/{id}",
|
||||
"DELETE /operationlog/batchDelete",
|
||||
"DELETE /operationlog/delete/{id}",
|
||||
"DELETE /position/batchDelete",
|
||||
"DELETE /position/delete/{id}",
|
||||
"DELETE /role/batchDelete",
|
||||
"DELETE /role/delete/{id}",
|
||||
"DELETE /user/batchDelete",
|
||||
"DELETE /user/delete/{id}",
|
||||
"GET /ad/detail/{id}",
|
||||
"GET /ad/page",
|
||||
"GET /adsort/detail/{id}",
|
||||
"GET /adsort/getAdSortList",
|
||||
"GET /adsort/page",
|
||||
"GET /captcha",
|
||||
"GET /city/detail/{id}",
|
||||
"GET /city/getChildList/{city_code}",
|
||||
"GET /city/page",
|
||||
"GET /config/detail/{id}",
|
||||
"GET /config/page",
|
||||
"GET /config/web",
|
||||
"GET /configitem/detail/{id}",
|
||||
"GET /configitem/page",
|
||||
"GET /dept/detail/{id}",
|
||||
"GET /dept/getDeptList",
|
||||
"GET /dept/list",
|
||||
"GET /dict/detail/{id}",
|
||||
"GET /dict/page",
|
||||
"GET /dictitem/detail/{id}",
|
||||
"GET /dictitem/page",
|
||||
"GET /index/menu",
|
||||
"GET /index/user",
|
||||
"GET /item/detail/{id}",
|
||||
"GET /item/getItemList",
|
||||
"GET /item/page",
|
||||
"GET /itemcate/detail/{id}",
|
||||
"GET /itemcate/getCateList",
|
||||
"GET /itemcate/page",
|
||||
"GET /job/detail/{id}",
|
||||
"GET /job/page",
|
||||
"GET /joblog/detail/{id}",
|
||||
"GET /joblog/page",
|
||||
"GET /level/detail/{id}",
|
||||
"GET /level/getLevelList",
|
||||
"GET /level/page",
|
||||
"GET /link/detail/{id}",
|
||||
"GET /link/page",
|
||||
"GET /loginlog/page",
|
||||
"GET /member/detail/{id}",
|
||||
"GET /member/page",
|
||||
"GET /memberlevel/detail/{id}",
|
||||
"GET /memberlevel/getMemberLevelList",
|
||||
"GET /memberlevel/page",
|
||||
"GET /menu/detail/{id}",
|
||||
"GET /menu/list",
|
||||
"GET /notice/detail/{id}",
|
||||
"GET /notice/page",
|
||||
"GET /operationlog/page",
|
||||
"GET /position/detail/{id}",
|
||||
"GET /position/getPositionList",
|
||||
"GET /position/page",
|
||||
"GET /role/detail/{id}",
|
||||
"GET /role/getRoleList",
|
||||
"GET /role/page",
|
||||
"GET /rolemenu/index/{role_id}",
|
||||
"GET /user/detail/{id}",
|
||||
"GET /user/page",
|
||||
"POST /ad/add",
|
||||
"POST /adsort/add",
|
||||
"POST /city/add",
|
||||
"POST /config/add",
|
||||
"POST /configitem/add",
|
||||
"POST /dept/add",
|
||||
"POST /dict/add",
|
||||
"POST /dictitem/add",
|
||||
"POST /index/logout",
|
||||
"POST /item/add",
|
||||
"POST /itemcate/add",
|
||||
"POST /job/add",
|
||||
"POST /job/reload",
|
||||
"POST /job/runOnce/{id}",
|
||||
"POST /level/add",
|
||||
"POST /level/export",
|
||||
"POST /level/import",
|
||||
"POST /link/add",
|
||||
"POST /login",
|
||||
"POST /member/add",
|
||||
"POST /memberlevel/add",
|
||||
"POST /menu/add",
|
||||
"POST /notice/add",
|
||||
"POST /position/add",
|
||||
"POST /role/add",
|
||||
"POST /rolemenu/save",
|
||||
"POST /upload/uploadEditImage",
|
||||
"POST /upload/uploadFile",
|
||||
"POST /upload/uploadImage",
|
||||
"POST /user/add",
|
||||
"POST /user/resetPwd",
|
||||
"PUT /ad/status",
|
||||
"PUT /ad/update",
|
||||
"PUT /adsort/update",
|
||||
"PUT /city/update",
|
||||
"PUT /config/update",
|
||||
"PUT /config/webSave",
|
||||
"PUT /configitem/status",
|
||||
"PUT /configitem/update",
|
||||
"PUT /dept/update",
|
||||
"PUT /dict/update",
|
||||
"PUT /dictitem/status",
|
||||
"PUT /dictitem/update",
|
||||
"PUT /index/updatePwd",
|
||||
"PUT /index/userInfo",
|
||||
"PUT /item/update",
|
||||
"PUT /itemcate/update",
|
||||
"PUT /job/pause/{id}",
|
||||
"PUT /job/resume/{id}",
|
||||
"PUT /job/status",
|
||||
"PUT /job/update",
|
||||
"PUT /level/status",
|
||||
"PUT /level/update",
|
||||
"PUT /link/status",
|
||||
"PUT /link/update",
|
||||
"PUT /member/status",
|
||||
"PUT /member/update",
|
||||
"PUT /memberlevel/update",
|
||||
"PUT /menu/update",
|
||||
"PUT /notice/update",
|
||||
"PUT /position/status",
|
||||
"PUT /position/update",
|
||||
"PUT /role/status",
|
||||
"PUT /role/update",
|
||||
"PUT /user/status",
|
||||
"PUT /user/update"
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
"fastapi_ad",
|
||||
"fastapi_ad_sort",
|
||||
"fastapi_city",
|
||||
"fastapi_config",
|
||||
"fastapi_config_data",
|
||||
"fastapi_dept",
|
||||
"fastapi_dict",
|
||||
"fastapi_dict_data",
|
||||
"fastapi_item",
|
||||
"fastapi_item_cate",
|
||||
"fastapi_job",
|
||||
"fastapi_job_log",
|
||||
"fastapi_level",
|
||||
"fastapi_link",
|
||||
"fastapi_login_log",
|
||||
"fastapi_member",
|
||||
"fastapi_member_level",
|
||||
"fastapi_menu",
|
||||
"fastapi_notice",
|
||||
"fastapi_operation_log",
|
||||
"fastapi_position",
|
||||
"fastapi_role",
|
||||
"fastapi_role_menu",
|
||||
"fastapi_user",
|
||||
"fastapi_user_role"
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""契约快照测试公共工具。
|
||||
|
||||
契约 = 前端/数据依赖的硬不变量:
|
||||
- 路由 (METHOD /path)
|
||||
- 权限字符串 (@permission_required("sys:..."))
|
||||
- 数据库表名
|
||||
|
||||
用法:设置环境变量 UPDATE_GOLDEN=1 运行一次以重新生成 golden。
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
|
||||
|
||||
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
|
||||
|
||||
UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
|
||||
|
||||
|
||||
def collect_routes():
|
||||
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
|
||||
from apps import create_app
|
||||
|
||||
app = create_app()
|
||||
spec = app.openapi()
|
||||
return sorted(
|
||||
f"{method.upper()} {path}"
|
||||
for path, methods in spec["paths"].items()
|
||||
for method in methods
|
||||
)
|
||||
|
||||
|
||||
def collect_permissions():
|
||||
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
|
||||
perms = set()
|
||||
for root in ("apps", "modules"):
|
||||
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
perms.update(PERMISSION_RE.findall(f.read()))
|
||||
return sorted(perms)
|
||||
|
||||
|
||||
def collect_tables():
|
||||
"""从 SQLAlchemy metadata 收集全部表名。"""
|
||||
from extensions.sqlalchemy import Base
|
||||
|
||||
return sorted(Base.metadata.tables.keys())
|
||||
|
||||
|
||||
def compare_snapshot(name: str, actual: list):
|
||||
"""与 golden 对比;UPDATE_GOLDEN=1 时重新生成并跳过断言。"""
|
||||
path = os.path.join(GOLDEN_DIR, name)
|
||||
if UPDATE_GOLDEN:
|
||||
os.makedirs(GOLDEN_DIR, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(actual, f, ensure_ascii=False, indent=2)
|
||||
return
|
||||
with open(path, encoding="utf-8") as f:
|
||||
golden = json.load(f)
|
||||
assert actual == golden, (
|
||||
f"契约快照 [{name}] 不一致!\n"
|
||||
f"新增: {sorted(set(actual) - set(golden))}\n"
|
||||
f"缺失: {sorted(set(golden) - set(actual))}"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""权限字符串契约快照:守护全部 RBAC 权限节点不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_permissions, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_permissions_snapshot():
|
||||
compare_snapshot("permissions.json", collect_permissions())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""路由契约快照:守护全部 API 路径与 HTTP 方法不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_routes, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_routes_snapshot():
|
||||
compare_snapshot("routes.json", collect_routes())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""表名契约快照:守护全部数据库表名(DB_PREFIX + 模块)不变。"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_tables, compare_snapshot
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_tablenames_snapshot():
|
||||
compare_snapshot("tables.json", collect_tables())
|
||||
Reference in New Issue
Block a user