580 lines
19 KiB
Python
580 lines
19 KiB
Python
"""
|
||
项目 API 测试(依据 API 文档 3–7 节)
|
||
|
||
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
|
||
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftest(admin / market / engineer 等,密码 123456)。
|
||
"""
|
||
|
||
|
||
def _auth_header(token: str) -> dict:
|
||
return {"Authorization": f"Bearer {token}"}
|
||
|
||
|
||
# ---------- POST /api/projects/list ----------
|
||
|
||
|
||
def test_list_without_token_returns_401(client):
|
||
"""项目列表无 token 时返回 401"""
|
||
r = client.post("/api/projects/list", json={})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_list_with_token_returns_200_with_list_total_page_page_size(client, auth_token):
|
||
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"page": 1, "pageSize": 20},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert isinstance(body["list"], list)
|
||
assert "total" in body
|
||
assert "page" in body
|
||
assert "pageSize" in body
|
||
|
||
|
||
def test_list_empty_result_has_empty_list_and_total_zero(client, auth_token):
|
||
"""列表返回 200;无数据时 list 为空且 total 为 0(有数据时仅校验结构)"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body and isinstance(body["list"], list)
|
||
assert "total" in body and isinstance(body["total"], int)
|
||
if len(body["list"]) == 0:
|
||
assert body["total"] == 0
|
||
|
||
|
||
def test_list_accepts_search_type_name_and_keyword(client, auth_token):
|
||
"""支持 searchType=name 与 keyword 搜索项目名称"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"searchType": "name", "keyword": "测试"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_accepts_search_type_code_and_keyword(client, auth_token):
|
||
"""支持 searchType=code 与 keyword 搜索项目编号"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"searchType": "code", "keyword": "HT001"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_accepts_progress_filter(client, auth_token):
|
||
"""支持按 progress 筛选项目进度"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"progress": "进行中"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_accepts_cost_filter(client, auth_token):
|
||
"""支持按 cost 筛选项目费用"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"cost": "100万以下"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_accepts_date_filter_and_date_range(client, auth_token):
|
||
"""支持 dateFilterType 与 dateFrom、dateTo 时间筛选"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={
|
||
"dateFilterType": "signDate",
|
||
"dateFrom": "2024-01-01",
|
||
"dateTo": "2024-12-31",
|
||
},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_accepts_date_abnormal_filter(client, auth_token):
|
||
"""支持 dateAbnormal=true 仅返回日期异常项目"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"dateAbnormal": True},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert isinstance(body["list"], list)
|
||
assert "total" in body
|
||
|
||
|
||
def test_list_items_contain_required_fields(client, auth_token, market_token):
|
||
"""列表项包含 id、projectName、contractCode、progress、cost、updatedAt"""
|
||
client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT-LIST", "projectName": "列表项字段测试"}},
|
||
)
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"page": 1, "pageSize": 20},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert len(body["list"]) >= 1
|
||
item = body["list"][0]
|
||
assert "id" in item
|
||
assert "projectName" in item
|
||
assert "contractCode" in item
|
||
assert "progress" in item
|
||
assert "cost" in item
|
||
assert "updatedAt" in item
|
||
|
||
|
||
def test_list_respects_page_and_page_size(client, auth_token):
|
||
"""请求 page、pageSize 时响应中 page、pageSize 与请求一致"""
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(auth_token),
|
||
json={"page": 2, "pageSize": 5},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body.get("page") == 2
|
||
assert body.get("pageSize") == 5
|
||
|
||
|
||
# ---------- POST /api/projects/detail ----------
|
||
|
||
|
||
def test_detail_without_token_returns_401(client):
|
||
"""项目详情无 token 时返回 401"""
|
||
r = client.post("/api/projects/detail", json={"id": "any-id"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_detail_existing_project_returns_200_with_full_fields(client, auth_token, market_token):
|
||
"""存在项目时返回 200 且包含 id、contract、costControl、receivable、payable、other、createdAt、updatedAt"""
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
r = client.post(
|
||
"/api/projects/detail",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "id" in body
|
||
assert "contract" in body
|
||
assert "costControl" in body
|
||
assert "receivable" in body
|
||
assert "payable" in body
|
||
assert "other" in body
|
||
assert "createdAt" in body
|
||
assert "updatedAt" in body
|
||
|
||
|
||
def test_detail_nonexistent_project_returns_404_with_code_and_message(client, auth_token):
|
||
"""不存在项目时返回 404 且包含 code 和 message"""
|
||
r = client.post(
|
||
"/api/projects/detail",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": "non-existent-id"},
|
||
)
|
||
assert r.status_code == 404
|
||
body = r.json()
|
||
assert body.get("code") == 404
|
||
assert "message" in body
|
||
|
||
|
||
def test_detail_missing_id_returns_400(client, auth_token):
|
||
"""缺少 id 时返回 400 或 422(参数错误)"""
|
||
r = client.post(
|
||
"/api/projects/detail",
|
||
headers=_auth_header(auth_token),
|
||
json={},
|
||
)
|
||
assert r.status_code in (400, 422)
|
||
body = r.json()
|
||
assert body.get("code") in (400, 422) or "detail" in body
|
||
|
||
|
||
# ---------- POST /api/projects/create ----------
|
||
|
||
|
||
def test_create_without_token_returns_401(client):
|
||
"""新建项目无 token 时返回 401"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||
)
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_create_non_market_role_returns_403(client, engineer_token):
|
||
"""非市场部角色返回 403"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(engineer_token),
|
||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json().get("code") == 403
|
||
|
||
|
||
def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client, market_token):
|
||
"""市场部且合同编号与项目名称齐全时返回 201 且包含 id 和 message"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||
)
|
||
assert r.status_code == 201
|
||
body = r.json()
|
||
assert "id" in body
|
||
assert body.get("message") == "创建成功"
|
||
|
||
|
||
def test_create_missing_contract_code_returns_400(client, market_token):
|
||
"""缺少合同编号时返回 400"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"projectName": "测试项目"}},
|
||
)
|
||
assert r.status_code == 400
|
||
assert r.json().get("code") == 400
|
||
|
||
|
||
def test_create_missing_project_name_returns_400(client, market_token):
|
||
"""缺少项目名称时返回 400"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT001"}},
|
||
)
|
||
assert r.status_code == 400
|
||
assert r.json().get("code") == 400
|
||
|
||
|
||
def test_create_validation_error_may_include_errors_array(client, market_token):
|
||
"""参数校验失败时响应可包含 errors 数组(field、message)"""
|
||
r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {}},
|
||
)
|
||
assert r.status_code == 400
|
||
body = r.json()
|
||
assert body.get("code") == 400
|
||
if "errors" in body and body["errors"]:
|
||
for e in body["errors"]:
|
||
assert "field" in e or "message" in e
|
||
|
||
|
||
# ---------- POST /api/projects/update ----------
|
||
|
||
|
||
def test_update_without_token_returns_401(client):
|
||
"""更新项目无 token 时返回 401"""
|
||
r = client.post("/api/projects/update", json={"id": "any-id"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_update_missing_id_returns_400(client, auth_token):
|
||
"""更新项目缺少 id 时返回 400 或 422(参数错误)"""
|
||
r = client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(auth_token),
|
||
json={"contract": {"projectName": "任意"}},
|
||
)
|
||
assert r.status_code in (400, 422)
|
||
body = r.json()
|
||
assert body.get("code") in (400, 422) or "detail" in body
|
||
|
||
|
||
def test_update_existing_project_returns_200_with_id_and_save_success_message(client, auth_token, market_token):
|
||
"""存在项目时返回 200 且包含 id 和 message「保存成功」"""
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
r = client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid, "contract": {"projectName": "新名称"}},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "id" in body
|
||
assert body.get("message") == "保存成功"
|
||
|
||
|
||
def test_update_nonexistent_project_returns_404(client, auth_token):
|
||
"""不存在项目时返回 404"""
|
||
r = client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": "non-existent-id"},
|
||
)
|
||
assert r.status_code == 404
|
||
assert r.json().get("code") == 404
|
||
|
||
|
||
# ---------- POST /api/projects/logs ----------
|
||
|
||
|
||
def test_logs_without_token_returns_401(client):
|
||
"""操作日志无 token 时返回 401"""
|
||
r = client.post("/api/projects/logs", json={"id": "any-id"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_logs_missing_id_returns_400(client, auth_token):
|
||
"""操作日志缺少 id 时返回 400 或 422(参数错误)"""
|
||
r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(auth_token),
|
||
json={},
|
||
)
|
||
assert r.status_code in (400, 422)
|
||
body = r.json()
|
||
assert body.get("code") in (400, 422) or "detail" in body
|
||
|
||
|
||
def test_logs_with_token_returns_200_with_list_total_page_page_size(client, auth_token, market_token):
|
||
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert isinstance(body["list"], list)
|
||
assert "total" in body
|
||
assert "page" in body
|
||
assert "pageSize" in body
|
||
|
||
|
||
def test_logs_nonexistent_project_returns_404(client, auth_token):
|
||
"""不存在项目时返回 404"""
|
||
r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": "non-existent-id"},
|
||
)
|
||
assert r.status_code == 404
|
||
assert r.json().get("code") == 404
|
||
|
||
|
||
def test_logs_after_update_returns_record_with_summary(client, auth_token, market_token):
|
||
"""更新项目后调用操作日志接口,至少返回一条记录且含 summary"""
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT-LOG", "projectName": "日志测试"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid, "contract": {"projectName": "日志测试已更新"}},
|
||
)
|
||
r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body
|
||
assert len(body["list"]) >= 1
|
||
first = body["list"][0]
|
||
assert "summary" in first
|
||
|
||
|
||
def test_logs_list_items_contain_required_fields(client, auth_token, market_token):
|
||
"""操作日志列表项包含 id、operatorId、operatorName、operatedAt、summary"""
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT-LOG2", "projectName": "日志字段测试"}},
|
||
)
|
||
pid = create_r.json()["id"]
|
||
client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid, "contract": {"projectName": "更新"}},
|
||
)
|
||
r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(auth_token),
|
||
json={"id": pid},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert len(body["list"]) >= 1
|
||
item = body["list"][0]
|
||
assert "id" in item
|
||
assert "operatorId" in item
|
||
assert "operatorName" in item
|
||
assert "operatedAt" in item
|
||
assert "summary" in item
|
||
|
||
|
||
# ---------- E2E:登录成功后 → 获取列表 → 创建项目 → 修改项目(完整流程)----------
|
||
|
||
|
||
def _login_and_get_token(client, username: str = "admin", password: str = "123456") -> str:
|
||
"""在用例内登录并返回 token,用于 E2E 流程。"""
|
||
r = client.post("/api/auth/login", json={"username": username, "password": password})
|
||
assert r.status_code == 200, f"登录失败: {r.status_code} {r.text}"
|
||
body = r.json()
|
||
assert "token" in body
|
||
return body["token"]
|
||
|
||
|
||
def test_e2e_login_then_get_project_list(client):
|
||
"""E2E:登录成功后获取项目列表,返回 200 且含 list、total、page、pageSize"""
|
||
token = _login_and_get_token(client, "admin", "123456")
|
||
r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(token),
|
||
json={"page": 1, "pageSize": 20},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "list" in body and isinstance(body["list"], list)
|
||
assert "total" in body and isinstance(body["total"], int)
|
||
assert body.get("page") == 1
|
||
assert body.get("pageSize") == 20
|
||
|
||
|
||
def test_e2e_login_then_create_project_then_list_includes_it(client):
|
||
"""E2E:登录(市场部)→ 创建项目 → 获取列表,列表中包含新建项目"""
|
||
token = _login_and_get_token(client, "market", "123456")
|
||
project_name = "E2E流程新建项目"
|
||
contract_code = "HT-E2E-001"
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(token),
|
||
json={"contract": {"contractCode": contract_code, "projectName": project_name}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
list_r = client.post(
|
||
"/api/projects/list",
|
||
headers=_auth_header(token),
|
||
json={"page": 1, "pageSize": 100},
|
||
)
|
||
assert list_r.status_code == 200
|
||
items = list_r.json().get("list", [])
|
||
ids = [p["id"] for p in items]
|
||
assert pid in ids
|
||
names = [p.get("projectName") for p in items if p.get("id") == pid]
|
||
assert project_name in names or len(names) >= 1
|
||
|
||
|
||
def test_e2e_login_then_create_then_detail_then_update_then_detail_reflects(client):
|
||
"""E2E:登录 → 创建项目 → 查详情 → 修改项目 → 再查详情,详情反映修改"""
|
||
market_token = _login_and_get_token(client, "market", "123456")
|
||
admin_token = _login_and_get_token(client, "admin", "123456")
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT-E2E-002", "projectName": "E2E修改前"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
detail_r = client.post(
|
||
"/api/projects/detail",
|
||
headers=_auth_header(admin_token),
|
||
json={"id": pid},
|
||
)
|
||
assert detail_r.status_code == 200
|
||
assert detail_r.json().get("contract", {}).get("projectName") == "E2E修改前"
|
||
update_r = client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(admin_token),
|
||
json={"id": pid, "contract": {"projectName": "E2E修改后"}},
|
||
)
|
||
assert update_r.status_code == 200
|
||
detail_r2 = client.post(
|
||
"/api/projects/detail",
|
||
headers=_auth_header(admin_token),
|
||
json={"id": pid},
|
||
)
|
||
assert detail_r2.status_code == 200
|
||
assert detail_r2.json().get("contract", {}).get("projectName") == "E2E修改后"
|
||
|
||
|
||
def test_e2e_login_then_create_and_update_then_logs_contain_record(client):
|
||
"""E2E:登录 → 创建项目 → 修改项目 → 获取操作日志,日志中含一条记录且含 summary"""
|
||
market_token = _login_and_get_token(client, "market", "123456")
|
||
admin_token = _login_and_get_token(client, "admin", "123456")
|
||
create_r = client.post(
|
||
"/api/projects/create",
|
||
headers=_auth_header(market_token),
|
||
json={"contract": {"contractCode": "HT-E2E-003", "projectName": "E2E日志测试"}},
|
||
)
|
||
assert create_r.status_code == 201
|
||
pid = create_r.json()["id"]
|
||
client.post(
|
||
"/api/projects/update",
|
||
headers=_auth_header(admin_token),
|
||
json={"id": pid, "contract": {"projectName": "E2E日志已更新"}},
|
||
)
|
||
logs_r = client.post(
|
||
"/api/projects/logs",
|
||
headers=_auth_header(admin_token),
|
||
json={"id": pid},
|
||
)
|
||
assert logs_r.status_code == 200
|
||
body = logs_r.json()
|
||
assert "list" in body and len(body["list"]) >= 1
|
||
assert "summary" in body["list"][0]
|