后端第一步开发完成,第一版回归测试完成
This commit is contained in:
+27
-7
@@ -2,23 +2,43 @@
|
||||
|
||||
依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。**
|
||||
|
||||
后端源码在 `backend/src/` 下,pytest 通过 `pytest.ini` 的 `pythonpath = src` 自动加入 `src` 目录,无需手动设置 PYTHONPATH。
|
||||
## 测试与开发环境严格隔离
|
||||
|
||||
- **不加载应用代码、不 mock 认证**:测试通过 HTTP 请求**已启动的后端服务**,与开发环境完全隔离。
|
||||
- **运行方式**:先启动后端服务(如 `uvicorn` 监听 `http://0.0.0.0:8000`),再在另一终端执行 `pytest tests/ -v`。
|
||||
- **服务地址**:默认 `http://127.0.0.1:8000`,可通过环境变量 `TEST_BASE_URL` 覆盖。
|
||||
|
||||
## 登录与带 Token 调用
|
||||
|
||||
- **登录**:`POST /api/auth/login`,请求体 `{"username": "admin", "password": "123456"}`(或 `market` / `engineer` / `tech` / `finance` / `material`,密码均为 `123456`)。
|
||||
- **带 Token 调接口**:在请求头中加 `Authorization: Bearer <登录返回的 token>`,再调用项目列表、详情、新建、更新、操作日志等接口。
|
||||
- **Fixture**:`conftest.py` 提供 `auth_token`(admin)、`market_token`(市场部)、`engineer_token`(工程部),用例中直接注入使用。
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 1. 先启动后端(在 backend 目录或项目根目录)
|
||||
uvicorn src.app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# 2. 在另一终端运行测试
|
||||
cd backend
|
||||
source .venv/bin/activate # 或 pip install -r requirements.txt
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
如需指定服务地址:
|
||||
|
||||
```bash
|
||||
TEST_BASE_URL=http://localhost:8000 pytest tests/ -v
|
||||
```
|
||||
|
||||
## 用例覆盖
|
||||
|
||||
| 接口 | 用例 |
|
||||
|------|------|
|
||||
| POST /api/auth/login | 成功返回 token/user、错误返回 401、缺 username/password 返回 400 |
|
||||
| POST /api/projects/list | 无 token 401、有 token 返回 list/total/page/pageSize、无数据时 list 空且 total=0 |
|
||||
| POST /api/projects/detail | 无 token 401、存在项目返回完整字段、不存在 404 |
|
||||
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400 |
|
||||
| POST /api/projects/update | 无 token 401、存在项目 200 保存成功、不存在 404 |
|
||||
| POST /api/projects/logs | 无 token 401、有 token 返回 list/total/page/pageSize、不存在项目 404 |
|
||||
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
|
||||
| POST /api/projects/list | 无 token 401;有 token 返回 list/total/page/pageSize;无数据 list 空且 total=0;支持 searchType+keyword(name/code)、progress、cost、dateFilterType+dateFrom/dateTo、dateAbnormal;列表项含 id/projectName/contractCode/progress/cost/updatedAt;page/pageSize 回显 |
|
||||
| POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
|
||||
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
|
||||
| POST /api/projects/update | 无 token 401、缺 id 400/422、存在项目 200 保存成功、不存在 404 |
|
||||
| POST /api/projects/logs | 无 token 401、缺 id 400/422、有 token 返回 list/total/page/pageSize、不存在项目 404、更新后有一条含 summary 的日志、列表项含 id/operatorId/operatorName/operatedAt/summary |
|
||||
|
||||
+53
-12
@@ -1,18 +1,59 @@
|
||||
"""pytest 配置与公共 fixture(依据 API 文档)"""
|
||||
"""pytest 配置与公共 fixture(依据 API 文档)
|
||||
|
||||
测试与开发环境严格隔离:测试通过 HTTP 请求已启动的后端服务,不加载应用代码、不 mock 认证。
|
||||
运行测试前请先启动后端:http://127.0.0.1:8000
|
||||
"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app import app, PROJECTS, OPERATION_LOGS
|
||||
|
||||
# 已启动的后端服务地址(可通过环境变量 TEST_BASE_URL 覆盖)
|
||||
BASE_URL = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:8000")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_store():
|
||||
"""每个测试前清空内存存储,保证隔离"""
|
||||
PROJECTS.clear()
|
||||
OPERATION_LOGS.clear()
|
||||
yield
|
||||
@pytest.fixture(scope="session")
|
||||
def http_client():
|
||||
"""请求已启动的后端服务的 HTTP 客户端(session 级,复用连接)"""
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""同步测试用:FastAPI TestClient"""
|
||||
return TestClient(app)
|
||||
def client(http_client):
|
||||
"""请求已启动的后端服务的 HTTP 客户端(测试用别名)"""
|
||||
return http_client
|
||||
|
||||
|
||||
def _login(client: httpx.Client, username: str, password: str) -> str:
|
||||
"""登录并返回 token,失败则抛出异常。"""
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"登录失败 {r.status_code}: {r.text}. 请确认后端已启动({BASE_URL})且存在用户 {username} / 密码 123456。"
|
||||
)
|
||||
body = r.json()
|
||||
if "token" not in body:
|
||||
raise RuntimeError(f"登录响应缺少 token: {body}")
|
||||
return body["token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_token(http_client):
|
||||
"""已登录的任意角色 token(admin,用于列表/详情/更新/日志等)"""
|
||||
return _login(http_client, "admin", "123456")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def market_token(http_client):
|
||||
"""市场部账号 token(仅市场部可新建项目)"""
|
||||
return _login(http_client, "market", "123456")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engineer_token(http_client):
|
||||
"""工程部账号 token(非市场部,用于 403 等用例)"""
|
||||
return _login(http_client, "engineer", "123456")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
认证 API 测试(依据 API 文档 2.1 登录)
|
||||
TDD:先写失败用例,再实现路由
|
||||
|
||||
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,真实调用登录接口。
|
||||
运行前请先启动后端:http://127.0.0.1:8000
|
||||
"""
|
||||
|
||||
|
||||
@@ -8,7 +10,7 @@ def test_login_success_returns_200_with_token_and_user(client):
|
||||
"""正确账号密码返回 200 且包含 token 和 user"""
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "market", "password": "valid-password"},
|
||||
json={"username": "admin", "password": "123456"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
项目 API 测试(依据 API 文档 3–7 节)
|
||||
TDD:先写失败用例,再实现路由
|
||||
|
||||
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
|
||||
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftest(admin / market / engineer 等,密码 123456)。
|
||||
"""
|
||||
|
||||
|
||||
@@ -17,11 +19,11 @@ def test_list_without_token_returns_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_list_with_token_returns_200_with_list_total_page_page_size(client):
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"page": 1, "pageSize": 20},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -33,16 +35,139 @@ def test_list_with_token_returns_200_with_list_total_page_page_size(client):
|
||||
assert "pageSize" in body
|
||||
|
||||
|
||||
def test_list_empty_result_has_empty_list_and_total_zero(client):
|
||||
"""无数据时 list 为空数组且 total 为 0"""
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["list"] == []
|
||||
assert r.json()["total"] == 0
|
||||
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 ----------
|
||||
@@ -54,18 +179,18 @@ def test_detail_without_token_returns_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_detail_existing_project_returns_200_with_full_fields(client):
|
||||
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"),
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -80,11 +205,11 @@ def test_detail_existing_project_returns_200_with_full_fields(client):
|
||||
assert "updatedAt" in body
|
||||
|
||||
|
||||
def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-id"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
@@ -93,6 +218,18 @@ def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
|
||||
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 ----------
|
||||
|
||||
|
||||
@@ -105,22 +242,22 @@ def test_create_without_token_returns_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_create_non_market_role_returns_403(client):
|
||||
def test_create_non_market_role_returns_403(client, engineer_token):
|
||||
"""非市场部角色返回 403"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("engineer-token"),
|
||||
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):
|
||||
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"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
@@ -129,28 +266,43 @@ def test_create_market_with_contract_code_and_name_returns_201_with_id_and_messa
|
||||
assert body.get("message") == "创建成功"
|
||||
|
||||
|
||||
def test_create_missing_contract_code_returns_400(client):
|
||||
def test_create_missing_contract_code_returns_400(client, market_token):
|
||||
"""缺少合同编号时返回 400"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
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):
|
||||
def test_create_missing_project_name_returns_400(client, market_token):
|
||||
"""缺少项目名称时返回 400"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
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 ----------
|
||||
|
||||
|
||||
@@ -160,18 +312,30 @@ def test_update_without_token_returns_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_update_existing_project_returns_200_with_id_and_save_success_message(client):
|
||||
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"),
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid, "contract": {"projectName": "新名称"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -180,11 +344,11 @@ def test_update_existing_project_returns_200_with_id_and_save_success_message(cl
|
||||
assert body.get("message") == "保存成功"
|
||||
|
||||
|
||||
def test_update_nonexistent_project_returns_404(client):
|
||||
def test_update_nonexistent_project_returns_404(client, auth_token):
|
||||
"""不存在项目时返回 404"""
|
||||
r = client.post(
|
||||
"/api/projects/update",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-id"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
@@ -200,18 +364,30 @@ def test_logs_without_token_returns_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
|
||||
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"),
|
||||
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("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -223,12 +399,68 @@ def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
|
||||
assert "pageSize" in body
|
||||
|
||||
|
||||
def test_logs_nonexistent_project_returns_404(client):
|
||||
def test_logs_nonexistent_project_returns_404(client, auth_token):
|
||||
"""不存在项目时返回 404"""
|
||||
r = client.post(
|
||||
"/api/projects/logs",
|
||||
headers=_auth_header("valid-token"),
|
||||
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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 后端 API 测试结果与反馈
|
||||
|
||||
**最近一次执行**:回归测试(后端工程师修复登录 500 后)
|
||||
**测试环境**:对已启动服务 `http://127.0.0.1:8000` 发起 HTTP 请求(测试与开发环境隔离)
|
||||
|
||||
---
|
||||
|
||||
## 回归测试结果汇总
|
||||
|
||||
| 结果 | 数量 |
|
||||
|------|------|
|
||||
| **通过** | **35** |
|
||||
| **失败** | 0 |
|
||||
| **错误** | 0 |
|
||||
| **合计** | 35 |
|
||||
|
||||
**结论:全部用例通过,回归测试通过。**
|
||||
|
||||
---
|
||||
|
||||
## 用例明细(35 个)
|
||||
|
||||
### 认证(4)
|
||||
|
||||
- test_login_success_returns_200_with_token_and_user ✓
|
||||
- test_login_wrong_credentials_returns_401_with_code_and_message ✓
|
||||
- test_login_missing_username_returns_400 ✓
|
||||
- test_login_missing_password_returns_400 ✓
|
||||
|
||||
### 项目列表(10)
|
||||
|
||||
- test_list_without_token_returns_401 ✓
|
||||
- test_list_with_token_returns_200_with_list_total_page_page_size ✓
|
||||
- test_list_empty_result_has_empty_list_and_total_zero ✓
|
||||
- test_list_accepts_search_type_name_and_keyword ✓
|
||||
- test_list_accepts_search_type_code_and_keyword ✓
|
||||
- test_list_accepts_progress_filter ✓
|
||||
- test_list_accepts_cost_filter ✓
|
||||
- test_list_accepts_date_filter_and_date_range ✓
|
||||
- test_list_accepts_date_abnormal_filter ✓
|
||||
- test_list_items_contain_required_fields ✓
|
||||
- test_list_respects_page_and_page_size ✓
|
||||
|
||||
### 项目详情(4)
|
||||
|
||||
- test_detail_without_token_returns_401 ✓
|
||||
- test_detail_existing_project_returns_200_with_full_fields ✓
|
||||
- test_detail_nonexistent_project_returns_404_with_code_and_message ✓
|
||||
- test_detail_missing_id_returns_400 ✓
|
||||
|
||||
### 新建项目(5)
|
||||
|
||||
- test_create_without_token_returns_401 ✓
|
||||
- test_create_non_market_role_returns_403 ✓
|
||||
- test_create_market_with_contract_code_and_name_returns_201_with_id_and_message ✓
|
||||
- test_create_missing_contract_code_returns_400 ✓
|
||||
- test_create_missing_project_name_returns_400 ✓
|
||||
- test_create_validation_error_may_include_errors_array ✓
|
||||
|
||||
### 更新项目(4)
|
||||
|
||||
- test_update_without_token_returns_401 ✓
|
||||
- test_update_missing_id_returns_400 ✓
|
||||
- test_update_existing_project_returns_200_with_id_and_save_success_message ✓
|
||||
- test_update_nonexistent_project_returns_404 ✓
|
||||
|
||||
### 操作日志(6)
|
||||
|
||||
- test_logs_without_token_returns_401 ✓
|
||||
- test_logs_missing_id_returns_400 ✓
|
||||
- test_logs_with_token_returns_200_with_list_total_page_page_size ✓
|
||||
- test_logs_nonexistent_project_returns_404 ✓
|
||||
- test_logs_after_update_returns_record_with_summary ✓
|
||||
- test_logs_list_items_contain_required_fields ✓
|
||||
|
||||
---
|
||||
|
||||
## 复现命令
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/ -v
|
||||
```
|
||||
Reference in New Issue
Block a user