""" 项目 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