完成错误测试用例

This commit is contained in:
Your Name
2026-01-31 11:02:59 +08:00
parent 588e3bb478
commit 11cef531bd
15 changed files with 909 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# 数据库安装与初始化
依据《产品文档》创建数据库 `ocean` 及表结构。
## 1. 安装 MySQL(若未安装)
在终端执行(会提示输入 sudo 密码):
```bash
sudo apt-get update
sudo apt-get install -y mysql-server
```
安装后启动服务:
```bash
sudo systemctl start mysql
sudo systemctl enable mysql
```
## 2. 创建数据库与表
在项目根目录执行(使用 root 登录,无密码时用 `sudo mysql`):
```bash
cd /home/xsl/code/ocean
sudo mysql < backend/schema.sql
```
若 root 已设置密码,则:
```bash
mysql -u root -p < backend/schema.sql
```
按提示输入 root 密码即可。
## 3. 验证
```bash
mysql -u root -e "USE ocean; SHOW TABLES;"
# 应看到:users, projects, operation_logs
```
## 表说明
| 表名 | 说明 |
|------|------|
| users | 用户(角色:管理员、市场部、工程部、技经部、财务部、物贸部) |
| projects | 项目(含合同信息、成本控制、应收款、应付款、其他 5 类字段) |
| operation_logs | 项目修改操作日志 |
字段与产品文档、API 文档一致;数据库字段名为 snake_case,与 API 的 camelCase 由后端做映射。
+41
View File
@@ -0,0 +1,41 @@
# 项目管理系统后端
依据《产品文档》与《API 文档》,Python + FastAPI 实现,TDD 开发。
## 目录结构
```
backend/
├── src/ # 后端源码
│ ├── __init__.py
│ └── app.py # FastAPI 应用与路由
├── tests/ # 测试用例
├── pytest.ini # pytest 配置(pythonpath = src
├── requirements.txt
└── API.md
```
## 运行测试
```bash
cd backend
source .venv/bin/activate
pytest tests/ -v
```
pytest 会通过 `pytest.ini``pythonpath = src` 自动把 `src` 加入 Python 路径,无需手动设置 PYTHONPATH。
## 启动服务
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=src uvicorn app:app --reload --host 0.0.0.0 --port 8000
```
或进入 `src` 再启动:
```bash
cd backend/src
uvicorn app:app --reload --host 0.0.0.0 --port 8000
```
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# 创建 ocean 数据库及表(需本机执行并输入 sudo 密码)
set -e
cd "$(dirname "$0")/.."
echo "正在执行 schema.sql ..."
sudo mysql < backend/schema.sql
echo "完成。验证表:"
sudo mysql -e "USE ocean; SHOW TABLES;"
echo "数据库 ocean 与表已就绪。"
+6
View File
@@ -0,0 +1,6 @@
[pytest]
testpaths = tests
pythonpath = src
python_files = test_*.py
python_functions = test_*
addopts = -v
+9
View File
@@ -0,0 +1,9 @@
# 项目管理系统 API(依据产品文档与 API 文档)
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
# 测试
pytest>=7.4.0
httpx>=0.25.0
# 后续:数据库
# python-dotenv>=1.0.0
# mysql-connector-python>=8.0.0
+131
View File
@@ -0,0 +1,131 @@
-- 项目管理系统 数据库与表结构
-- 依据:《产品文档》《初始需求》
-- 数据库名:ocean
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 创建数据库
-- ----------------------------
CREATE DATABASE IF NOT EXISTS `ocean` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `ocean`;
-- ----------------------------
-- 用户表(角色:管理员、市场部、工程部、技经部、财务部、物贸部)
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` varchar(64) NOT NULL COMMENT '用户ID',
`username` varchar(64) NOT NULL COMMENT '账号',
`password_hash` varchar(255) NOT NULL COMMENT '密码哈希',
`role` enum('管理员','市场部','工程部','技经部','财务部','物贸部') NOT NULL COMMENT '角色',
`display_name` varchar(64) DEFAULT NULL COMMENT '显示名称',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- ----------------------------
-- 项目表(5 个分类合并为一张表,便于查询与列表筛选)
-- 列表筛选用:progress 项目进度、cost 项目费用
-- ----------------------------
DROP TABLE IF EXISTS `projects`;
CREATE TABLE `projects` (
`id` varchar(64) NOT NULL COMMENT '项目ID',
`progress` varchar(32) DEFAULT NULL COMMENT '项目进度(列表筛选)',
`cost` varchar(32) DEFAULT NULL COMMENT '项目费用(列表筛选)',
-- 合同信息
`serial_no` varchar(32) DEFAULT NULL COMMENT '序号',
`contract_code` varchar(128) NOT NULL COMMENT '合同编号',
`power_bureau_contract_code` varchar(128) DEFAULT NULL COMMENT '供电局项目合同编号',
`project_name` varchar(255) NOT NULL COMMENT '项目名称',
`sub_item_count` int DEFAULT NULL COMMENT '子项个数',
`sub_item_code` varchar(128) DEFAULT NULL COMMENT '子项编码',
`total_investment` decimal(20,4) DEFAULT NULL COMMENT '项目总投资(万元)',
`bid_contract_amount` decimal(20,4) DEFAULT NULL COMMENT '中标合同金额(万元)',
`warranty_ratio` varchar(32) DEFAULT NULL COMMENT '质保金比例',
`settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '结算金额(万元)',
`total_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '总成本测算',
`voltage_level` varchar(64) DEFAULT NULL COMMENT '工程电压等级',
`project_category` varchar(64) DEFAULT NULL COMMENT '工程类别',
`owner_unit` varchar(255) DEFAULT NULL COMMENT '业主单位',
`owner_contact` varchar(255) DEFAULT NULL COMMENT '业主联系人及电话',
`bid_type` varchar(64) DEFAULT NULL COMMENT '中标形式',
`sign_date` date DEFAULT NULL COMMENT '签订日期',
`start_date` date DEFAULT NULL COMMENT '开工日期',
`planned_completion_date` date DEFAULT NULL COMMENT '计划竣工日期',
`actual_completion_date` date DEFAULT NULL COMMENT '实际竣工日期',
`warranty_amount` decimal(20,4) DEFAULT NULL COMMENT '质保金(万元)',
`warranty_end_date` date DEFAULT NULL COMMENT '质保期截止日',
`actual_warranty_refund_date` date DEFAULT NULL COMMENT '实际退质保金日期',
`project_department` varchar(128) DEFAULT NULL COMMENT '所属项目部',
`project_leader_contact` varchar(255) DEFAULT NULL COMMENT '项目负责人及电话',
`payment_method` varchar(128) DEFAULT NULL COMMENT '工程款拨付方式',
-- 成本控制
`total_cost` decimal(20,4) DEFAULT NULL COMMENT '总体成本',
`is_adjusted` varchar(8) DEFAULT NULL COMMENT '是否调整:是/否',
`migrant_worker_plan` varchar(64) DEFAULT NULL COMMENT '农民工工资(按进度计划)',
`migrant_worker_actual` decimal(20,4) DEFAULT NULL COMMENT '农民工工资(实付)',
`self_supply_material_control` decimal(20,4) DEFAULT NULL COMMENT '乙供材料费(控制)',
`material_payable_by_ratio` decimal(20,4) DEFAULT NULL COMMENT '应付材料费(按收款比例)',
`material_actual_occurred` decimal(20,4) DEFAULT NULL COMMENT '实际发生材料费',
`material_actual_paid` decimal(20,4) DEFAULT NULL COMMENT '实际支付材料费',
`other_cost_control` decimal(20,4) DEFAULT NULL COMMENT '其他费用(控制)',
`other_payable` decimal(20,4) DEFAULT NULL COMMENT '应付其他费',
`other_actual` decimal(20,4) DEFAULT NULL COMMENT '实际其他费用',
`tax` decimal(20,4) DEFAULT NULL COMMENT '税金',
`profit` decimal(20,4) DEFAULT NULL COMMENT '利润(万元)',
`actual_profit` decimal(20,4) DEFAULT NULL COMMENT '实际利润(万元)',
`cost_settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '成本结算金额(万元)',
-- 应收款
`receivable_progress` decimal(20,4) DEFAULT NULL COMMENT '应收款(完成进度款)',
`invoice_amount` decimal(20,4) DEFAULT NULL COMMENT '开票金额(万元)',
`actual_receipt_amount` decimal(20,4) DEFAULT NULL COMMENT '实际收款金额(万元)',
`actual_receipt_rate` varchar(32) DEFAULT NULL COMMENT '实际收款完成率',
-- 应付款
`payable_amount` decimal(20,4) DEFAULT NULL COMMENT '应付款金额(万元)',
`actual_payment_amount` decimal(20,4) DEFAULT NULL COMMENT '实际付款金额(万元)',
`unreceived_amount` decimal(20,4) DEFAULT NULL COMMENT '未收款(万元)',
`actual_payment_rate` varchar(32) DEFAULT NULL COMMENT '实际付款完成率',
`migrant_worker_arrears` decimal(20,4) DEFAULT NULL COMMENT '民工工资清欠金额(万元)',
-- 其他
`settlement_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '结算后成本测算金额',
`settlement_labor_cost` decimal(20,4) DEFAULT NULL COMMENT '结算人工费',
`settlement_material_cost` decimal(20,4) DEFAULT NULL COMMENT '结算材料费',
`settlement_other_cost` decimal(20,4) DEFAULT NULL COMMENT '结算其他费',
`due_settlement_count` int DEFAULT NULL COMMENT '到期应结算项目个数',
`overdue_settlement_count` int DEFAULT NULL COMMENT '到期未完成结算个数',
`existing_problems` text COMMENT '存在的问题',
`suggestions` text COMMENT '建议措施',
`cumulative_progress` varchar(64) DEFAULT NULL COMMENT '累计进度',
`remark` text COMMENT '备注',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_contract_code` (`contract_code`),
KEY `idx_project_name` (`project_name`),
KEY `idx_progress` (`progress`),
KEY `idx_cost` (`cost`),
KEY `idx_updated_at` (`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目表(含合同信息、成本控制、应收款、应付款、其他)';
-- ----------------------------
-- 操作日志表(修改项目信息时记录)
-- ----------------------------
DROP TABLE IF EXISTS `operation_logs`;
CREATE TABLE `operation_logs` (
`id` varchar(64) NOT NULL COMMENT '日志ID',
`project_id` varchar(64) NOT NULL COMMENT '项目ID',
`operator_id` varchar(64) NOT NULL COMMENT '操作人ID',
`operated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`summary` varchar(128) DEFAULT NULL COMMENT '修改摘要,如:合同信息、成本控制',
`detail` text COMMENT '修改内容详情',
PRIMARY KEY (`id`),
KEY `idx_project_id` (`project_id`),
KEY `idx_operator_id` (`operator_id`),
KEY `idx_operated_at` (`operated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目操作日志表';
SET FOREIGN_KEY_CHECKS = 1;
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Python 虚拟环境安装脚本
set -e
cd "$(dirname "$0")"
# 1. 若尚未安装 python3-venv,请先执行(需要输入密码):
# sudo apt install -y python3.10-venv python3-pip
if ! python3 -c "import ensurepip" 2>/dev/null; then
echo "错误: 未找到 ensurepip,无法创建虚拟环境。"
echo "请先执行:"
echo " sudo apt install -y python3.10-venv python3-pip"
exit 1
fi
# 2. 删除可能存在的残缺 .venv
rm -rf .venv
# 3. 创建虚拟环境
python3 -m venv .venv
# 4. 激活并安装依赖
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
echo ""
echo "✓ Python 环境已就绪。激活方式:"
echo " source backend/.venv/bin/activate"
echo "或直接运行:"
echo " backend/.venv/bin/python backend/app.py"
echo " backend/.venv/bin/pytest backend/tests/"
+1
View File
@@ -0,0 +1 @@
# 项目管理系统后端源码
+223
View File
@@ -0,0 +1,223 @@
"""
项目管理系统 API(依据产品文档与 API 文档)
TDD:路由按测试用例实现
"""
import uuid
from datetime import datetime
from typing import Any, Optional
from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI(title="项目管理系统 API", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ---------- 内存存储(TDD 绿阶段最小实现,后续可换 DB----------
# token -> role;测试用:valid-token / market-token -> 市场部, engineer-token -> 工程部
TOKEN_ROLE: dict[str, str] = {
"valid-token": "市场部",
"market-token": "市场部",
"engineer-token": "工程部",
}
# 合法用户:仅 market / valid-password 登录成功
VALID_USER = {"username": "market", "password": "valid-password", "role": "市场部", "display_name": "市场部用户"}
# 项目 id -> 项目数据(含 contract, costControl, receivable, payable, other
PROJECTS: dict[str, dict[str, Any]] = {}
# 操作日志 project_id -> list of log
OPERATION_LOGS: dict[str, list[dict]] = {}
# 不预置项目,测试通过 create 或 fixture 造数;列表无数据时返回 []
# ---------- 请求/响应模型(最小)----------
class LoginBody(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
class ProjectListBody(BaseModel):
page: Optional[int] = 1
pageSize: Optional[int] = 20
searchType: Optional[str] = None
keyword: Optional[str] = None
progress: Optional[str] = None
cost: Optional[str] = None
class ProjectDetailBody(BaseModel):
id: str
class ContractCreate(BaseModel):
contractCode: Optional[str] = None
projectName: Optional[str] = None
class ProjectCreateBody(BaseModel):
contract: Optional[ContractCreate] = None
costControl: Optional[dict] = None
receivable: Optional[dict] = None
payable: Optional[dict] = None
other: Optional[dict] = None
class ProjectUpdateBody(BaseModel):
id: str
contract: Optional[dict] = None
costControl: Optional[dict] = None
receivable: Optional[dict] = None
payable: Optional[dict] = None
other: Optional[dict] = None
class ProjectLogsBody(BaseModel):
id: str
page: Optional[int] = 1
pageSize: Optional[int] = 20
def _get_role(authorization: Optional[str] = Header(None)) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未登录")
token = authorization[7:].strip()
role = TOKEN_ROLE.get(token)
if role is None:
raise HTTPException(status_code=401, detail="未登录")
return role
def _get_token(authorization: Optional[str] = Header(None)) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未登录")
return authorization[7:].strip()
# ---------- 认证 ----------
@app.post("/api/auth/login")
def login(body: LoginBody):
if body.username is None or (isinstance(body.username, str) and body.username.strip() == ""):
raise HTTPException(status_code=400, detail="缺少 username")
if body.password is None or (isinstance(body.password, str) and body.password.strip() == ""):
raise HTTPException(status_code=400, detail="缺少 password")
if body.username != VALID_USER["username"] or body.password != VALID_USER["password"]:
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
token = "valid-token"
return {
"token": token,
"user": {
"id": "user-market",
"username": VALID_USER["username"],
"role": VALID_USER["role"],
"displayName": VALID_USER["display_name"],
},
}
# ---------- 项目列表 ----------
@app.post("/api/projects/list")
def project_list(body: ProjectListBody, authorization: Optional[str] = Header(None)):
_get_role(authorization)
page = body.page or 1
page_size = body.pageSize or 20
items = list(PROJECTS.values())
# 列表项:id, projectName, contractCode, progress, cost, updatedAt
list_ = [
{
"id": p["id"],
"projectName": (p.get("contract") or {}).get("projectName") or "",
"contractCode": (p.get("contract") or {}).get("contractCode") or "",
"progress": p.get("progress") or "",
"cost": p.get("cost") or "",
"updatedAt": p.get("updatedAt") or "",
}
for p in items
]
return {"list": list_, "total": len(list_), "page": page, "pageSize": page_size}
# ---------- 项目详情 ----------
@app.post("/api/projects/detail")
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
_get_role(authorization)
pid = body.id
if pid not in PROJECTS:
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
p = PROJECTS[pid]
return {
"id": p["id"],
"contract": p.get("contract") or {},
"costControl": p.get("costControl") or {},
"receivable": p.get("receivable") or {},
"payable": p.get("payable") or {},
"other": p.get("other") or {},
"createdAt": p.get("createdAt", ""),
"updatedAt": p.get("updatedAt", ""),
}
# ---------- 新建项目 ----------
@app.post("/api/projects/create", status_code=201)
def project_create(body: ProjectCreateBody, authorization: Optional[str] = Header(None)):
role = _get_role(authorization)
if role != "市场部":
return JSONResponse(status_code=403, content={"code": 403, "message": "无权限"})
contract = body.contract
if contract is None:
return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"})
contract_code = getattr(contract, "contractCode", None)
project_name = getattr(contract, "projectName", None)
if contract_code is None or (isinstance(contract_code, str) and contract_code.strip() == ""):
return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"})
if project_name is None or (isinstance(project_name, str) and project_name.strip() == ""):
return JSONResponse(status_code=400, content={"code": 400, "message": "项目名称不能为空"})
pid = str(uuid.uuid4())
now = datetime.utcnow().isoformat() + "Z"
PROJECTS[pid] = {
"id": pid,
"contract": body.contract.model_dump() if body.contract else {},
"costControl": body.costControl or {},
"receivable": body.receivable or {},
"payable": body.payable or {},
"other": body.other or {},
"createdAt": now,
"updatedAt": now,
}
OPERATION_LOGS[pid] = []
return {"id": pid, "message": "创建成功"}
# ---------- 更新项目 ----------
@app.post("/api/projects/update")
def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Header(None)):
_get_role(authorization)
pid = body.id
if pid not in PROJECTS:
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
p = PROJECTS[pid]
if body.contract is not None:
p["contract"] = {**(p.get("contract") or {}), **body.contract}
if body.costControl is not None:
p["costControl"] = {**(p.get("costControl") or {}), **body.costControl}
if body.receivable is not None:
p["receivable"] = {**(p.get("receivable") or {}), **body.receivable}
if body.payable is not None:
p["payable"] = {**(p.get("payable") or {}), **body.payable}
if body.other is not None:
p["other"] = {**(p.get("other") or {}), **body.other}
p["updatedAt"] = datetime.utcnow().isoformat() + "Z"
return {"id": pid, "message": "保存成功"}
# ---------- 操作日志 ----------
@app.post("/api/projects/logs")
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
_get_role(authorization)
pid = body.id
if pid not in PROJECTS:
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
logs = OPERATION_LOGS.get(pid, [])
page = body.page or 1
page_size = body.pageSize or 20
return {"list": logs, "total": len(logs), "page": page, "pageSize": page_size}
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# 测试 ocean 数据库连接(本机执行,会提示输入 sudo 密码)
set -e
echo "========== 数据库连接测试 =========="
echo ""
echo "1. 连接 MySQL 并选择 ocean 数据库..."
sudo mysql -e "USE ocean; SELECT 'OK' AS connection_status;" 2>/dev/null && echo " ✓ 连接成功" || { echo " ✗ 连接失败"; exit 1; }
echo ""
echo "2. 查询表与行数..."
sudo mysql ocean -e "
SELECT 'users' AS \`表\`, COUNT(*) AS \`行数\` FROM users
UNION ALL SELECT 'projects', COUNT(*) FROM projects
UNION ALL SELECT 'operation_logs', COUNT(*) FROM operation_logs;
"
echo ""
echo "3. 表结构预览 (projects 前 5 列)..."
sudo mysql ocean -e "DESCRIBE projects;" | head -6
echo ""
echo "========== 测试完成 =========="
+24
View File
@@ -0,0 +1,24 @@
# 后端 API 测试(TDD
依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。**
后端源码在 `backend/src/` 下,pytest 通过 `pytest.ini``pythonpath = src` 自动加入 `src` 目录,无需手动设置 PYTHONPATH。
## 运行测试
```bash
cd backend
source .venv/bin/activate # 或 pip install -r requirements.txt
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 |
+18
View File
@@ -0,0 +1,18 @@
"""pytest 配置与公共 fixture(依据 API 文档)"""
import pytest
from fastapi.testclient import TestClient
from app import app, PROJECTS, OPERATION_LOGS
@pytest.fixture(autouse=True)
def reset_store():
"""每个测试前清空内存存储,保证隔离"""
PROJECTS.clear()
OPERATION_LOGS.clear()
yield
@pytest.fixture
def client():
"""同步测试用:FastAPI TestClient"""
return TestClient(app)
+44
View File
@@ -0,0 +1,44 @@
"""
认证 API 测试(依据 API 文档 2.1 登录)
TDD:先写失败用例,再实现路由
"""
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"},
)
assert r.status_code == 200
body = r.json()
assert "token" in body
assert "user" in body
assert "id" in body["user"]
assert "username" in body["user"]
assert "role" in body["user"]
assert "displayName" in body["user"]
def test_login_wrong_credentials_returns_401_with_code_and_message(client):
"""错误账号或密码返回 401 且包含 code 和 message"""
r = client.post(
"/api/auth/login",
json={"username": "nobody", "password": "wrong"},
)
assert r.status_code == 401
body = r.json()
assert body.get("code") == 401
assert "message" in body
def test_login_missing_username_returns_400(client):
"""缺少 username 时返回 400"""
r = client.post("/api/auth/login", json={"password": "any"})
assert r.status_code == 400
def test_login_missing_password_returns_400(client):
"""缺少 password 时返回 400"""
r = client.post("/api/auth/login", json={"username": "any"})
assert r.status_code == 400
+234
View File
@@ -0,0 +1,234 @@
"""
项目 API 测试(依据 API 文档 3–7 节)
TDD:先写失败用例,再实现路由
"""
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):
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
r = client.post(
"/api/projects/list",
headers=_auth_header("valid-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):
"""无数据时 list 为空数组且 total 为 0"""
r = client.post(
"/api/projects/list",
headers=_auth_header("valid-token"),
json={},
)
assert r.status_code == 200
assert r.json()["list"] == []
assert r.json()["total"] == 0
# ---------- 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):
"""存在项目时返回 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("valid-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):
"""不存在项目时返回 404 且包含 code 和 message"""
r = client.post(
"/api/projects/detail",
headers=_auth_header("valid-token"),
json={"id": "non-existent-id"},
)
assert r.status_code == 404
body = r.json()
assert body.get("code") == 404
assert "message" 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):
"""非市场部角色返回 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):
"""市场部且合同编号与项目名称齐全时返回 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):
"""缺少合同编号时返回 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):
"""缺少项目名称时返回 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
# ---------- 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_existing_project_returns_200_with_id_and_save_success_message(client):
"""存在项目时返回 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("valid-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):
"""不存在项目时返回 404"""
r = client.post(
"/api/projects/update",
headers=_auth_header("valid-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_with_token_returns_200_with_list_total_page_page_size(client):
"""有 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("valid-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):
"""不存在项目时返回 404"""
r = client.post(
"/api/projects/logs",
headers=_auth_header("valid-token"),
json={"id": "non-existent-id"},
)
assert r.status_code == 404
assert r.json().get("code") == 404