630 lines
27 KiB
Python
630 lines
27 KiB
Python
"""端到端自检脚本(不依赖 pytest,直接 python tests/api_check.py 就能跑)。
|
||
|
||
它按"一个教务老师一天的活儿"的顺序把系统走一遍:
|
||
建班 -> 建老师/顾问 -> 录学生(含 Excel 批量)-> 录成绩(触发红线)->
|
||
登记就业(触发状态流转)-> 转班(验证冗余字段同步)-> 删就业(状态回退)->
|
||
各项统计 -> 高级筛选 -> 权限与异常分支。
|
||
|
||
用法:
|
||
python tests/api_check.py [--base http://127.0.0.1:8000]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import traceback
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
|
||
BASE = "http://127.0.0.1:8000"
|
||
|
||
PASS, FAIL, WARN = [], [], []
|
||
|
||
|
||
class Ctx:
|
||
token: str | None = None
|
||
client: httpx.Client
|
||
|
||
|
||
def call(method: str, path: str, *, expect_code: int = 0, **kw):
|
||
"""发一个请求并断言业务 code。"""
|
||
headers = kw.pop("headers", {})
|
||
if Ctx.token:
|
||
headers["Authorization"] = f"Bearer {Ctx.token}"
|
||
response = Ctx.client.request(method, path, headers=headers, **kw)
|
||
try:
|
||
body = response.json()
|
||
except Exception: # noqa: BLE001
|
||
body = {"code": -1, "msg": response.text[:200], "data": None}
|
||
if expect_code is not None and body.get("code") != expect_code:
|
||
raise AssertionError(
|
||
f"{method} {path} 期望 code={expect_code},实际 code={body.get('code')} msg={body.get('msg')}"
|
||
)
|
||
return body
|
||
|
||
|
||
def step(title: str):
|
||
"""装饰器:包一层异常捕获,定义即执行。"""
|
||
|
||
def decorator(func):
|
||
def wrapper():
|
||
try:
|
||
detail = func()
|
||
PASS.append((title, detail))
|
||
print(f" [PASS] {title} {detail or ''}")
|
||
except AssertionError as exc:
|
||
FAIL.append((title, str(exc)))
|
||
print(f" [FAIL] {title}\n {exc}")
|
||
except Exception as exc: # noqa: BLE001
|
||
FAIL.append((title, f"{type(exc).__name__}: {exc}"))
|
||
print(f" [FAIL] {title}\n {type(exc).__name__}: {exc}")
|
||
|
||
wrapper()
|
||
return func
|
||
|
||
return decorator
|
||
|
||
|
||
# ==================================================================== 只读接口
|
||
def check_reads():
|
||
print("\n== 第一部分:登录与只读接口 ==")
|
||
|
||
@step("健康检查")
|
||
def _():
|
||
data = call("GET", "/api/health")["data"]
|
||
return f"dialect={data['dialect']}"
|
||
|
||
@step("未登录访问受保护接口应被拒绝")
|
||
def _():
|
||
token = Ctx.token
|
||
Ctx.token = None
|
||
body = call("GET", "/students", expect_code=None)
|
||
Ctx.token = token
|
||
assert body["code"] == 401, f"期望 401,实际 {body['code']}"
|
||
return body["msg"]
|
||
|
||
@step("错误密码登录")
|
||
def _():
|
||
body = call("POST", "/auth/login", expect_code=None, json={"username": "admin", "password": "wrong"})
|
||
assert body["code"] == 401, f"期望 401,实际 {body['code']}"
|
||
return body["msg"]
|
||
|
||
@step("学生列表 + 分页")
|
||
def _():
|
||
data = call("GET", "/students?page=1&page_size=5")["data"]
|
||
assert len(data["items"]) == 5
|
||
assert data["pages"] >= 1
|
||
return f"total={data['total']} pages={data['pages']}"
|
||
|
||
@step("学生按班级编号筛选")
|
||
def _():
|
||
data = call("GET", "/students?class_no=JAVA202601&page_size=100")["data"]
|
||
assert data["total"] > 0
|
||
assert all(i["class_no"] == "JAVA202601" for i in data["items"])
|
||
return f"{data['total']} 人"
|
||
|
||
@step("学生按年龄区间筛选")
|
||
def _():
|
||
data = call("GET", "/students?age_min=20&age_max=24&page_size=100")["data"]
|
||
assert all(20 <= i["age"] <= 24 for i in data["items"])
|
||
return f"{data['total']} 人"
|
||
|
||
for path, name in [
|
||
("/classes?page_size=100", "班级列表"),
|
||
("/teachers?page_size=100", "老师列表(含带班)"),
|
||
("/advisors?page_size=100", "顾问列表"),
|
||
("/scores?page_size=5", "成绩列表"),
|
||
("/employments?page_size=5", "就业列表"),
|
||
("/students/meta/options", "学生下拉选项"),
|
||
("/classes/meta/options", "班级下拉选项"),
|
||
("/scores/meta", "成绩元信息"),
|
||
("/employments?salary_min=15000&salary_max=8000&page_size=5", "薪资区间写反自动纠正"),
|
||
]:
|
||
path_local, name_local = path, name
|
||
|
||
@step(name_local)
|
||
def _(p=path_local):
|
||
body = call("GET", p)
|
||
return f"code={body['code']}"
|
||
|
||
@step("就业按公司模糊 + 薪资区间查询")
|
||
def _():
|
||
data = call("GET", "/employments?company=科技&salary_min=10000&page_size=100")["data"]
|
||
return f"{data['total']} 条"
|
||
|
||
|
||
# ==================================================================== 写流程
|
||
def check_write_flow():
|
||
print("\n== 第二部分:完整业务链路 ==")
|
||
stamp = date.today().strftime("%m%d")
|
||
|
||
ctx: dict = {}
|
||
|
||
@step("新增顾问")
|
||
def _():
|
||
body = call("POST", "/advisors", json={"name": f"自检顾问{stamp}", "gender": 2, "dept": "自检部"})
|
||
ctx["advisor_id"] = body["data"]["id"]
|
||
return f"编号 {body['data']['advisor_no']}"
|
||
|
||
@step("新增老师(工号自动生成 T+年份+序号)")
|
||
def _():
|
||
body = call("POST", "/teachers", json={"name": f"自检老师{stamp}", "gender": 1, "subject": "自检方向"})
|
||
ctx["teacher_id"] = body["data"]["id"]
|
||
assert body["data"]["teacher_no"].startswith("T")
|
||
return f"工号 {body['data']['teacher_no']}"
|
||
|
||
@step("新增班级(编号自动生成为 方向+年份+序号)")
|
||
def _():
|
||
body = call(
|
||
"POST", "/classes",
|
||
json={
|
||
"name": f"自检班{stamp}", "direction": "SelfTest", "capacity": 30,
|
||
"open_date": "2026-09-01", "head_teacher_id": ctx["teacher_id"],
|
||
"advisor_id": ctx["advisor_id"], "teacher_ids": [ctx["teacher_id"]],
|
||
},
|
||
)
|
||
data = body["data"]
|
||
ctx["class_id"] = data["id"]
|
||
assert data["teacher_ids"] == [ctx["teacher_id"]], "多对多老师没保存上"
|
||
return f"编号 {data['class_no']}"
|
||
|
||
@step("新增学生(学号按规则生成)")
|
||
def _():
|
||
body = call(
|
||
"POST", "/students",
|
||
json={
|
||
"name": f"自检学生A{stamp}", "gender": "女", "birth_date": "2003-04-05",
|
||
"native_place": "广东广州", "graduate_school": "自检学院", "major": "软件",
|
||
"education": "大专", "enroll_date": "2026-09-01", "class_id": ctx["class_id"],
|
||
"advisor_id": ctx["advisor_id"], "phone": "13000000001",
|
||
},
|
||
)
|
||
ctx["stu_a"] = body["data"]["id"]
|
||
ctx["stu_a_no"] = body["data"]["stu_no"]
|
||
assert body["data"]["age"] and body["data"]["class_name"]
|
||
return f"学号 {body['data']['stu_no']},年龄 {body['data']['age']},班级 {body['data']['class_name']}"
|
||
|
||
@step("只给年龄不给生日也能建学生(birth_date_estimated=1)")
|
||
def _():
|
||
body = call(
|
||
"POST", "/students",
|
||
json={"name": f"自检学生B{stamp}", "gender": 1, "age": 21, "class_id": ctx["class_id"]},
|
||
)
|
||
ctx["stu_b"] = body["data"]["id"]
|
||
assert body["data"]["birth_date_estimated"] == 1
|
||
assert body["data"]["age"] == 21
|
||
return f"学号 {body['data']['stu_no']},推算生日 {body['data']['birth_date']}"
|
||
|
||
@step("学号重复应被拒绝")
|
||
def _():
|
||
body = call(
|
||
"POST", "/students", expect_code=None,
|
||
json={"name": "重号测试", "gender": 1, "age": 20, "stu_no": ctx["stu_a_no"]},
|
||
)
|
||
assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
@step("毕业时间早于入学时间应被拒绝")
|
||
def _():
|
||
body = call(
|
||
"POST", "/students", expect_code=None,
|
||
json={"name": "日期测试", "gender": 1, "age": 20, "enroll_date": "2026-09-01", "graduate_date": "2025-01-01"},
|
||
)
|
||
assert body["code"] == 400, f"期望 400,实际 {body['code']}"
|
||
return body["msg"]
|
||
|
||
@step("录入正常成绩")
|
||
def _():
|
||
body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 1, "score": 88, "exam_name": "自检一考"})
|
||
assert body["data"]["warning"] is False
|
||
return f"均分 {body['data']['avg_score']}"
|
||
|
||
@step("录入低分成绩应触发红线预警")
|
||
def _():
|
||
body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 45, "exam_name": "自检二考"})
|
||
data = body["data"]
|
||
assert data["warning"] is True, "没触发预警"
|
||
assert data["record"]["is_warning"] is True
|
||
return data["warning_msg"]
|
||
|
||
@step("预警会写进学生备注")
|
||
def _():
|
||
body = call("GET", f"/students/{ctx['stu_a']}")
|
||
remark = body["data"]["remark"] or ""
|
||
assert "成绩预警" in remark, f"备注里没有预警:{remark}"
|
||
return remark[:60]
|
||
|
||
@step("同一序次重复录入应提示改用修改")
|
||
def _():
|
||
body = call(
|
||
"POST", "/scores", expect_code=None,
|
||
json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 70},
|
||
)
|
||
assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
@step("修改成绩后预警标记自动撤销")
|
||
def _():
|
||
rows = call("GET", f"/scores?stu_id={ctx['stu_a']}&exam_seq=2")["data"]["items"]
|
||
assert rows, "没查到第 2 次考核成绩"
|
||
score_id = rows[0]["id"]
|
||
body = call("PUT", f"/scores/{score_id}", json={"score": 91})
|
||
assert body["data"]["warning"] is False, f"分数改到 91 分还在预警:{body['data']}"
|
||
return f"均分更新为 {body['data']['avg_score']}"
|
||
|
||
@step("批量录入同一学生多次成绩")
|
||
def _():
|
||
body = call(
|
||
"POST", "/scores/batch",
|
||
json={"stu_id": ctx["stu_b"], "scores": [
|
||
{"stu_id": ctx["stu_b"], "exam_seq": 1, "score": 55},
|
||
{"stu_id": ctx["stu_b"], "exam_seq": 2, "score": 92},
|
||
{"stu_id": ctx["stu_b"], "exam_seq": 3, "score": 58},
|
||
]},
|
||
)
|
||
data = body["data"]
|
||
assert data["count"] == 3
|
||
assert data["warning_count"] == 2, f"应预警 2 条,实际 {data['warning_count']}"
|
||
return body["msg"]
|
||
|
||
@step("登记就业:只填开放时间 -> 学生状态变「进入就业」")
|
||
def _():
|
||
body = call(
|
||
"POST", "/employments",
|
||
json={"stu_id": ctx["stu_a"], "open_date": "2026-09-10", "company": "自检科技", "salary": 12000},
|
||
)
|
||
data = body["data"]
|
||
assert data["status_changed"] is True, f"状态没变化:{data}"
|
||
assert data["to_status_text"] == "进入就业", data
|
||
ctx["emp_id"] = data["employment"]["id"]
|
||
return body["msg"]
|
||
|
||
@step("补填 offer 时间 -> 学生状态变「已就业」")
|
||
def _():
|
||
body = call(
|
||
"PUT", f"/employments/{ctx['emp_id']}",
|
||
json={"offer_date": "2026-09-25"},
|
||
)
|
||
data = body["data"]
|
||
assert data["to_status_text"] == "已就业", data
|
||
assert data["employment"]["duration_days"] == 15, data["employment"]["duration_days"]
|
||
return body["msg"]
|
||
|
||
@step("offer 时间早于开放时间应被拒绝")
|
||
def _():
|
||
body = call("PUT", f"/employments/{ctx['emp_id']}", expect_code=None, json={"offer_date": "2026-09-01"})
|
||
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
@step("只填 offer 不填开放时间应被拒绝(否则就业时长算不出来)")
|
||
def _():
|
||
body = call(
|
||
"POST", "/employments", expect_code=None,
|
||
json={"stu_id": ctx["stu_b"], "offer_date": "2026-09-20", "company": "X", "salary": 9000},
|
||
)
|
||
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
@step("学生转班 -> 就业表冗余班级同步")
|
||
def _():
|
||
classes = call("GET", "/classes?page_size=100")["data"]["items"]
|
||
other = next(c for c in classes if c["id"] != ctx["class_id"])
|
||
call("PUT", f"/students/{ctx['stu_a']}", json={"class_id": other["id"]})
|
||
emp = call("GET", f"/employments/student/{ctx['stu_a']}")["data"]
|
||
assert emp["class_id"] == other["id"], f"冗余班级没同步:{emp['class_id']} != {other['id']}"
|
||
ctx["other_class_id"] = other["id"]
|
||
return f"已同步到「{emp['class_name']}」"
|
||
|
||
@step("删除就业记录 -> 学生状态回退到「在读」")
|
||
def _():
|
||
body = call("DELETE", f"/employments/{ctx['emp_id']}")
|
||
assert body["data"]["status_changed"] is True
|
||
student = call("GET", f"/students/{ctx['stu_a']}")["data"]
|
||
assert student["status"] == 1, student
|
||
return body["msg"]
|
||
|
||
@step("有学生的班级不能被删")
|
||
def _():
|
||
body = call("DELETE", f"/classes/{ctx['other_class_id']}", expect_code=None)
|
||
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
@step("Excel 模板可下载")
|
||
def _():
|
||
headers = {"Authorization": f"Bearer {Ctx.token}"}
|
||
response = Ctx.client.get("/students/import/template", headers=headers)
|
||
assert response.status_code == 200 and len(response.content) > 1000
|
||
ctx["template"] = response.content
|
||
return f"{len(response.content)} 字节"
|
||
|
||
@step("Excel 批量导入(3 行有效 + 1 行班级不存在)")
|
||
def _():
|
||
from io import BytesIO
|
||
|
||
from openpyxl import Workbook
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.append(["姓名", "性别", "出生日期", "班级编号", "顾问", "学历", "籍贯", "备注"])
|
||
classes = call("GET", "/classes?page_size=100")["data"]["items"]
|
||
class_no = classes[0]["class_no"]
|
||
ws.append([f"导入甲{stamp}", "男", "2004-01-02", class_no, "", "大专", "湖南", "自检"])
|
||
ws.append([f"导入乙{stamp}", "女", "2003-02-03", class_no, "", "本科", "湖北", "自检"])
|
||
ws.append([f"导入丙{stamp}", "男", "2002-03-04", "", "", "大专", "江西", "自检"])
|
||
ws.append([f"导入丁{stamp}", "男", "2002-03-04", "NOT-EXIST-999", "", "大专", "江西", ""])
|
||
buffer = BytesIO()
|
||
wb.save(buffer)
|
||
result = call(
|
||
"POST", "/students/import?dry_run=false",
|
||
files={"file": ("import.xlsx", buffer.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||
)["data"]
|
||
assert result["success"] == 3, f"应成功 3 行,实际 {result['success']}:{result['errors']}"
|
||
assert result["failed"] == 1, f"应失败 1 行,实际 {result['failed']}"
|
||
ctx["imported"] = [f"导入甲{stamp}", f"导入乙{stamp}", f"导入丙{stamp}"]
|
||
return f"成功 {result['success']},失败 {result['failed']},失败原因:{result['errors'][0]['reason']}"
|
||
|
||
@step("导入的学生确实入库了")
|
||
def _():
|
||
data = call("GET", f"/students?keyword=导入&page_size=100")["data"]
|
||
assert data["total"] >= 3, f"只查到 {data['total']} 个"
|
||
return f"{data['total']} 人"
|
||
|
||
@step("逻辑删除学生")
|
||
def _():
|
||
call("DELETE", f"/students/{ctx['stu_b']}")
|
||
body = call("GET", f"/students/{ctx['stu_b']}", expect_code=None)
|
||
assert body["code"] == 404, f"删除后还能查到:{body}"
|
||
return body["msg"]
|
||
|
||
@step("恢复被删除的学生")
|
||
def _():
|
||
call("POST", f"/students/{ctx['stu_b']}/restore")
|
||
body = call("GET", f"/students/{ctx['stu_b']}")
|
||
return f"已恢复 {body['data']['name']}"
|
||
|
||
return ctx
|
||
|
||
|
||
# ==================================================================== 统计与筛选
|
||
def check_statistics(ctx: dict):
|
||
print("\n== 第三部分:统计分析与高级筛选 ==")
|
||
|
||
checks = [
|
||
("2.6.1 动态年龄查询 gte", "/statistics/students/by-age?operator=gte&value=24&page_size=100"),
|
||
("2.6.1 动态年龄查询 between", "/statistics/students/by-age?operator=between&value=20&value2=24&page_size=100"),
|
||
("2.6.1 班级人数与性别分布", "/statistics/classes/overview"),
|
||
("2.6.1 年龄分布", "/statistics/students/age-distribution"),
|
||
("2.6.2 每考都在 80 分以上", "/statistics/scores/all-above?threshold=80"),
|
||
("2.6.2 不及格 2 次以上", "/statistics/scores/failures?threshold=60&min_times=2"),
|
||
("2.6.2 各班各场平均分(降序)", "/statistics/scores/class-average?order=desc"),
|
||
("2.6.2 各班各场平均分(升序)", "/statistics/scores/class-average?order=asc&exam_seq=1"),
|
||
("2.6.3 薪资 Top 5", "/statistics/employment/top-salary?top_n=5"),
|
||
("2.6.3 每人就业时长", "/statistics/employment/durations"),
|
||
("2.6.3 各班平均就业时长", "/statistics/employment/class-avg-duration"),
|
||
("2.7.2 成绩波动 Top 5(标准差)", "/statistics/scores/volatility?top_n=5&metric=stddev"),
|
||
("2.7.2 成绩波动 Top 5(最大分差)", "/statistics/scores/volatility?top_n=5&metric=range"),
|
||
("2.7.2 班级就业漏斗", "/statistics/employment/funnel"),
|
||
("总览", "/statistics/overview"),
|
||
]
|
||
for name, path in checks:
|
||
path_local, name_local = path, name
|
||
|
||
@step(name_local)
|
||
def _(p=path_local):
|
||
body = call("GET", p)
|
||
data = body["data"]
|
||
count = len(data) if isinstance(data, list) else len(data.get("items", [])) if isinstance(data, dict) else "-"
|
||
return f"code={body['code']} 返回 {count} 项"
|
||
|
||
# ---------------- 高级筛选
|
||
@step("2.7.1 需求文档原始示例(年龄+性别+嵌套 OR 薪资/班级)")
|
||
def _():
|
||
body = call("POST", "/advanced/query", json={
|
||
"model": "student",
|
||
"rules": [
|
||
{"field": "age", "operator": ">", "value": 25},
|
||
{"field": "gender", "operator": "=", "value": "男"},
|
||
{"logic": "OR", "sub_rules": [
|
||
{"field": "salary", "operator": ">=", "value": 15000},
|
||
{"field": "class_name", "operator": "like", "value": "Java"},
|
||
]},
|
||
],
|
||
"order_by": "salary", "order": "desc", "page": 1, "page_size": 10,
|
||
})
|
||
data = body["data"]
|
||
assert data["explain"], "没有返回 SQL 说明"
|
||
return f"命中 {data['total']} 条"
|
||
|
||
@step("2.7.1 嵌套两组 AND/OR 三层")
|
||
def _():
|
||
body = call("POST", "/advanced/query", json={
|
||
"model": "student",
|
||
"rules": [
|
||
{"logic": "AND", "sub_rules": [
|
||
{"field": "age", "operator": "between", "value": [19, 26]},
|
||
{"logic": "OR", "sub_rules": [
|
||
{"field": "education", "operator": "in", "value": ["大专", "本科"]},
|
||
{"logic": "AND", "sub_rules": [
|
||
{"field": "major", "operator": "like", "value": "软件"},
|
||
{"field": "gender", "operator": "=", "value": 2},
|
||
]},
|
||
]},
|
||
]},
|
||
],
|
||
"page_size": 5,
|
||
})
|
||
return f"命中 {body['data']['total']} 条"
|
||
|
||
@step("2.7.1 操作符 != / not_like / is_null / not_null / not_in")
|
||
def _():
|
||
total = 0
|
||
for rule in [
|
||
{"field": "gender", "operator": "!=", "value": "男"},
|
||
{"field": "major", "operator": "not_like", "value": "软件"},
|
||
{"field": "remark", "operator": "is_null"},
|
||
{"field": "phone", "operator": "not_null"},
|
||
{"field": "education", "operator": "not_in", "value": ["中专"]},
|
||
]:
|
||
body = call("POST", "/advanced/query", json={"model": "student", "rules": [rule], "page_size": 1})
|
||
total += body["data"]["total"]
|
||
return f"五种操作符均可用,命中数合计 {total}"
|
||
|
||
@step("2.7.1 非法字段给出可用字段提示")
|
||
def _():
|
||
body = call("POST", "/advanced/query", expect_code=422, json={
|
||
"model": "student", "rules": [{"field": "salary2", "operator": ">", "value": 1}]
|
||
})
|
||
assert "可用字段" in body["msg"]
|
||
return body["msg"][:50]
|
||
|
||
@step("2.7.1 非法操作符被拒")
|
||
def _():
|
||
return call("POST", "/advanced/query", expect_code=422, json={
|
||
"model": "student", "rules": [{"field": "age", "operator": "; drop table", "value": 1}]
|
||
})["msg"][:50]
|
||
|
||
@step("2.7.1 嵌套过深被拒")
|
||
def _():
|
||
rule = {"field": "age", "operator": ">", "value": 1}
|
||
for _i in range(10):
|
||
rule = {"logic": "AND", "sub_rules": [rule]}
|
||
body = call("POST", "/advanced/query", expect_code=422, json={"model": "student", "rules": [rule]})
|
||
return body["msg"][:50]
|
||
|
||
@step("2.7.2 通用聚合:按班级统计人数/平均分/最高分")
|
||
def _():
|
||
body = call("POST", "/advanced/aggregate", json={
|
||
"model": "score", "group_by": ["class_name"],
|
||
"metrics": [{"func": "count", "alias": "成绩数"},
|
||
{"func": "avg", "field": "score", "alias": "平均分"},
|
||
{"func": "max", "field": "score", "alias": "最高分"}],
|
||
"order_by": "平均分", "order": "desc", "limit": 10,
|
||
})
|
||
return f"{len(body['data']['rows'])} 行,列:{body['data']['columns']}"
|
||
|
||
@step("2.7.2 聚合 + HAVING 过滤")
|
||
def _():
|
||
body = call("POST", "/advanced/aggregate", json={
|
||
"model": "employment", "group_by": ["class_name"],
|
||
"metrics": [{"func": "count", "alias": "就业人数"},
|
||
{"func": "avg", "field": "salary", "alias": "平均薪资"}],
|
||
"having": [{"field": "平均薪资", "operator": ">", "value": 13000}],
|
||
"order_by": "平均薪资", "order": "desc", "limit": 10,
|
||
})
|
||
assert all(r["平均薪资"] > 13000 for r in body["data"]["rows"])
|
||
return f"{len(body['data']['rows'])} 个班达标"
|
||
|
||
# ---------------- 权限
|
||
@step("只读账号不能写")
|
||
def _():
|
||
admin_token = Ctx.token
|
||
Ctx.token = None
|
||
viewer = call("POST", "/auth/login", json={"username": "viewer", "password": "viewer123"})["data"]["access_token"]
|
||
token = Ctx.token
|
||
Ctx.token = viewer
|
||
body = call("POST", "/students", expect_code=None, json={"name": "越权", "gender": 1, "age": 20})
|
||
Ctx.token = admin_token
|
||
assert body["code"] == 403, f"期望 403,实际 {body['code']} {body['msg']}"
|
||
return body["msg"]
|
||
|
||
|
||
def cleanup_selftest() -> None:
|
||
"""删掉自检造出来的业务数据,避免污染演示库。
|
||
|
||
自检用的名字都带 `自检` / `导入` 前缀 + 当天日期戳(如 自检学生A0916),
|
||
这里按「前缀 + 日期戳」精确匹配删除,不会误伤正常的演示数据。
|
||
|
||
走 ORM 而不是 HTTP:要按外键顺序连删 成绩/就业/学生/班级/老师/顾问 + 中间表,
|
||
用 SQL 一次搞定最可靠。导入失败(比如脚本被拷到别的机器上跑)只提示一句,不算测试失败。
|
||
"""
|
||
stamp = date.today().strftime("%m%d")
|
||
# 以 `python tests/api_check.py` 方式运行时,sys.path[0] 是 tests/,
|
||
# 直接 import app 会失败 —— 这里把项目根补进搜索路径。
|
||
project_root = str(Path(__file__).resolve().parent.parent)
|
||
if project_root not in sys.path:
|
||
sys.path.insert(0, project_root)
|
||
try:
|
||
from sqlalchemy import or_
|
||
|
||
from app.core.database import SessionLocal
|
||
from app.model import Advisor, Clazz, Employment, Score, Student, Teacher, class_teachers
|
||
except Exception as exc: # noqa: BLE001
|
||
print(f" [SKIP] 连不上数据库,跳过清理(要清理请跑 seed_data.py --reset):{exc}")
|
||
return
|
||
|
||
def name_hit(model):
|
||
return or_(model.name.like(f"%自检%{stamp}%"), model.name.like(f"%导入%{stamp}%"))
|
||
|
||
counts: dict[str, int] = {}
|
||
with SessionLocal() as db:
|
||
stu_ids = [r[0] for r in db.query(Student.id).filter(name_hit(Student)).all()]
|
||
cls_ids = [r[0] for r in db.query(Clazz.id).filter(name_hit(Clazz)).all()]
|
||
tea_ids = [r[0] for r in db.query(Teacher.id).filter(name_hit(Teacher)).all()]
|
||
adv_ids = [r[0] for r in db.query(Advisor.id).filter(name_hit(Advisor)).all()]
|
||
|
||
if stu_ids:
|
||
counts["成绩"] = db.query(Score).filter(Score.stu_id.in_(stu_ids)).delete(synchronize_session=False)
|
||
counts["就业"] = db.query(Employment).filter(Employment.stu_id.in_(stu_ids)).delete(
|
||
synchronize_session=False
|
||
)
|
||
counts["学生"] = db.query(Student).filter(Student.id.in_(stu_ids)).delete(synchronize_session=False)
|
||
if cls_ids:
|
||
db.execute(class_teachers.delete().where(class_teachers.c.class_id.in_(cls_ids)))
|
||
counts["班级"] = db.query(Clazz).filter(Clazz.id.in_(cls_ids)).delete(synchronize_session=False)
|
||
if tea_ids:
|
||
db.execute(class_teachers.delete().where(class_teachers.c.teacher_id.in_(tea_ids)))
|
||
counts["老师"] = db.query(Teacher).filter(Teacher.id.in_(tea_ids)).delete(synchronize_session=False)
|
||
if adv_ids:
|
||
counts["顾问"] = db.query(Advisor).filter(Advisor.id.in_(adv_ids)).delete(synchronize_session=False)
|
||
db.commit()
|
||
|
||
hit = ",".join(f"{k} {v} 条" for k, v in counts.items() if v)
|
||
print(f" [清理] 自检数据已删除:{hit or '无残留'}")
|
||
|
||
|
||
def main():
|
||
global BASE
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--base", default=BASE)
|
||
parser.add_argument("--keep", action="store_true", help="保留自检造的数据(默认跑完会清掉)")
|
||
args = parser.parse_args()
|
||
BASE = args.base
|
||
|
||
Ctx.client = httpx.Client(base_url=BASE, timeout=60)
|
||
|
||
print("\n== 登录 ==")
|
||
try:
|
||
body = call("POST", "/auth/login", json={"username": "admin", "password": "admin123"})
|
||
Ctx.token = body["data"]["access_token"]
|
||
print(f" [PASS] 管理员登录:{body['msg']}")
|
||
PASS.append(("管理员登录", body["msg"]))
|
||
except Exception as exc: # noqa: BLE001
|
||
print(f" [FAIL] 登录失败:{exc}")
|
||
FAIL.append(("管理员登录", str(exc)))
|
||
sys.exit(1)
|
||
|
||
check_reads()
|
||
ctx = check_write_flow()
|
||
check_statistics(ctx)
|
||
|
||
if not args.keep:
|
||
print("\n== 收尾清理 ==")
|
||
cleanup_selftest()
|
||
|
||
print("\n" + "=" * 70)
|
||
print(f"通过 {len(PASS)} 项,失败 {len(FAIL)} 项")
|
||
if FAIL:
|
||
print("\n失败明细:")
|
||
for title, reason in FAIL:
|
||
print(f" - {title}\n {reason}")
|
||
sys.exit(2)
|
||
print("全部通过 ✅")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|