diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f5e5a0 --- /dev/null +++ b/Makefile @@ -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 . diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2439d29 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..89d2bee --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +minversion = 7.0 +addopts = -ra --strict-markers +testpaths = tests +pythonpath = . +markers = + contract: 契约快照测试(路由/权限串/表名),守护前端与数据契约 + integration: 需要真实 MySQL/Redis 的集成测试,默认跳过 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..27cd91d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# 开发/测试依赖 +pytest>=7.4 +pytest-asyncio>=0.23 +httpx>=0.27 +aiosqlite>=0.19 +fakeredis>=2.20 diff --git a/requirements.txt b/requirements.txt index 0bc1207..39fa915 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ddff4b0 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/golden/permissions.json b/tests/contract/golden/permissions.json new file mode 100644 index 0000000..2cca38d --- /dev/null +++ b/tests/contract/golden/permissions.json @@ -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" +] \ No newline at end of file diff --git a/tests/contract/golden/routes.json b/tests/contract/golden/routes.json new file mode 100644 index 0000000..a366c79 --- /dev/null +++ b/tests/contract/golden/routes.json @@ -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" +] \ No newline at end of file diff --git a/tests/contract/golden/tables.json b/tests/contract/golden/tables.json new file mode 100644 index 0000000..bc42d36 --- /dev/null +++ b/tests/contract/golden/tables.json @@ -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" +] \ No newline at end of file diff --git a/tests/contract/helpers.py b/tests/contract/helpers.py new file mode 100644 index 0000000..34a83b7 --- /dev/null +++ b/tests/contract/helpers.py @@ -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))}" + ) diff --git a/tests/contract/test_permission_snapshot.py b/tests/contract/test_permission_snapshot.py new file mode 100644 index 0000000..1b4c5fb --- /dev/null +++ b/tests/contract/test_permission_snapshot.py @@ -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()) diff --git a/tests/contract/test_routes_snapshot.py b/tests/contract/test_routes_snapshot.py new file mode 100644 index 0000000..e3f8d89 --- /dev/null +++ b/tests/contract/test_routes_snapshot.py @@ -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()) diff --git a/tests/contract/test_tablename_snapshot.py b/tests/contract/test_tablename_snapshot.py new file mode 100644 index 0000000..034811f --- /dev/null +++ b/tests/contract/test_tablename_snapshot.py @@ -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())