""" 项目 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/statistics(API 文档第 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]