Files
ocean/backend/tests/test_projects_api.py
T
2026-02-01 15:57:42 +08:00

638 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
项目 API 测试(依据 API 文档 3–7 节)
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftestadmin / market / engineer 等,密码 123456)。
"""
def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ---------- POST /api/projects/statisticsAPI 文档第 3 节)----------
def test_statistics_without_token_returns_401(client):
"""项目统计无 token 时返回 401"""
r = client.post("/api/projects/statistics", json={})
assert r.status_code == 401
def test_statistics_with_token_default_returns_200_with_total_and_by_type(client, auth_token):
"""有 token 且未传 statisticsType(或传全部)时返回 200,含 total、statisticsType、byType"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={},
)
assert r.status_code == 200
body = r.json()
assert "total" in body
assert isinstance(body["total"], int)
assert "statisticsType" in body
assert body["statisticsType"] == "全部"
assert "byType" in body
assert isinstance(body["byType"], dict)
def test_statistics_with_statistics_type_all_returns_by_type(client, auth_token):
"""statisticsType=全部 时返回 byType 各类型数量"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={"statisticsType": "全部"},
)
assert r.status_code == 200
body = r.json()
assert body.get("statisticsType") == "全部"
assert "byType" in body
for _, count in body["byType"].items():
assert isinstance(count, (int, float))
def test_statistics_with_specific_type_returns_200_with_total_and_list(client, auth_token):
"""statisticsType 为具体类型时返回 200,含 total、statisticsType、list"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={"statisticsType": "基建工程"},
)
assert r.status_code == 200
body = r.json()
assert "total" in body
assert isinstance(body["total"], int)
assert "statisticsType" in body
assert body["statisticsType"] == "基建工程"
assert "list" in body
assert isinstance(body["list"], list)
# ---------- 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": 20, "searchType": "name", "keyword": project_name},
)
assert list_r.status_code == 200
items = list_r.json().get("list", [])
ids = [p["id"] for p in items]
assert pid in ids, f"新建项目 id {pid} 应在搜索结果中,list ids: {ids[:5]}..."
found = next((p for p in items if p.get("id") == pid), None)
assert found is not None and found.get("projectName") == project_name
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]