""" Task 2 批量导入冒烟脚本 — httpx(幂等版,可重复跑) 确保后端已启动: py -3.12 -c "import sys; sys.path.insert(0, r'E:\tps-dashboard\backend'); import uvicorn; uvicorn.run('app.main:app', host='127.0.0.1', port=8000, reload=False)" Phase 2 Task 2 实测清单覆盖: Step 1: GET /api/imports/template → 模板下载 Step 2: POST /api/imports/episodes → 合法行单行导入 Step 3: POST /api/imports/episodes → 部分失败行(成功+失败混合格式) Step 4: GET /api/imports/errors/{id} → 失败行 Excel 下载 Step 5: POST /api/imports/episodes → 2024年份缺失拦截 → 400 Step 6: POST /api/imports/episodes → 重复期次拦截 → 409 Step 7: POST /api/imports/episodes → 未知编导软落地 → 200 + editor_id=null """ import io import httpx import random from openpyxl import Workbook BASE = "http://localhost:8000" def login(username: str = "simonkoson", password: str = "liutong65") -> str: r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password}) r.raise_for_status() return r.cookies.get("milsci_session", "") def step(name: str, expected_status: int, method: str, url: str, **kwargs): cookies = kwargs.pop("cookies", {}) if method == "GET": r = httpx.get(url, cookies=cookies, timeout=30.0, **kwargs) elif method == "POST": r = httpx.post(url, cookies=cookies, timeout=30.0, **kwargs) elif method == "DELETE": r = httpx.delete(url, cookies=cookies, timeout=30.0, **kwargs) else: raise ValueError(method) status_ok = r.status_code == expected_status mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]" print(f" {mark} [{method}] {name}") if not status_ok: print(f" Response: {r.text[:300]}") return r, status_ok def make_excel(rows: list[list]) -> bytes: """构建简单 xlsx,rows[0] 为表头,后续为数据。""" wb = Workbook() ws = wb.active for row in rows: ws.append(row) output = io.BytesIO() wb.save(output) output.seek(0) return output.read() def cleanup_test_episodes(cookies: dict): """清理测试用 episode_number(幂等清理)。""" r = httpx.get(f"{BASE}/api/episodes?limit=100", cookies=cookies) if r.status_code == 200: for ep in r.json(): num = ep.get("episode_number", 0) # 清理本次测试可能用到的期号段 if num in (99, 100, 101, 102, 200, 301): httpx.delete(f"{BASE}/api/episodes/{ep['id']}", cookies=cookies) def main(): print("=== Login ===") session_cookie = login() if not session_cookie: print("WARNING: session cookie empty") session_cookie = "" cookies = {"milsci_session": session_cookie} # 先清理旧测试数据(幂等) print("\n=== Cleanup old test episodes ===") cleanup_test_episodes(cookies) suffix = random.randint(1000, 9999) # ---- Step 1: 模板下载 ---- print("\n=== Step 1: Template Download ===") r, ok = step( "GET /api/imports/template?type=episodes", 200, "GET", f"{BASE}/api/imports/template?type=episodes", cookies=cookies, ) content_type = r.headers.get("content-type", "") is_binary = "application/vnd.openxmlformats" in content_type print(f" {'[OK]' if is_binary else '[FAIL]'} Content-Type: {content_type}") print(f" [INFO] Template downloaded ({len(r.content)} bytes)") # ---- Step 2: 合法单行导入 ---- print("\n=== Step 2: Normal import (1 valid row) ===") excel_data = make_excel([ ["episode_number", "program_name", "air_date", "editor_name", "audience_share", "audience_rating", "is_rerun", "notes"], [99, "《军事科技》实测专用", "2026-06-01", "张颖", 0.75, 3.8, "否", "Plan smoke test"], ]) r, ok = step( "POST /api/imports/episodes (1 valid row) → 200", 200, "POST", f"{BASE}/api/imports/episodes", files={"file": ("test_valid.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, cookies=cookies, ) if ok: result = r.json() print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}") ok = (result["success_count"] == 1 and result["failed_count"] == 0) print(f" {'[OK]' if ok else '[FAIL]'} Row count correct") # ---- Step 3: 部分失败行回显 ---- print("\n=== Step 3: Partial failure (3 rows: 1 ok + 2 bad) ===") excel_data = make_excel([ ["episode_number", "program_name", "air_date", "editor_name", "audience_share", "audience_rating", "is_rerun", "notes"], [100, "《军事科技》成功行", "2026-07-01", "张颖", 0.71, 3.5, "否", ""], [101, "无效日期格式行", "不是日期", "张颖", 0.72, 3.6, "否", ""], [102, "非整数期号行", "invalid-ep-num", "张颖", 0.73, 3.7, "否", ""], ]) r, ok = step( "POST /api/imports/episodes (1ok+2bad) → 200 with errors", 200, "POST", f"{BASE}/api/imports/episodes", files={"file": ("test_mixed.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, cookies=cookies, ) batch_id = None if ok: result = r.json() print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}") ok_count = (result["success_count"] == 1 and result["failed_count"] == 2) print(f" {'[OK]' if ok_count else '[FAIL]'} Failure count correct") # 检查 row_number 是否对齐 Excel 行号(第2行=Excel第2行,第3行=Excel第3行,第4行=Excel第4行) if result["errors"]: row_nums = sorted([e["row_number"] for e in result["errors"]]) print(f" [INFO] error row_numbers: {row_nums}") ok_rows = (row_nums == [3, 4]) print(f" {'[OK]' if ok_rows else '[FAIL]'} row_number对齐Excel自然行号(表头第1行,数据从第2行起算)") batch_id = result.get("batch_id") if batch_id: print(f" [INFO] batch_id={batch_id}") # ---- Step 4: 失败行下载 ---- print("\n=== Step 4: Error Excel Download ===") if batch_id: r, ok = step( f"GET /api/imports/errors/{batch_id} → 200 binary", 200, "GET", f"{BASE}/api/imports/errors/{batch_id}", cookies=cookies, ) ct = r.headers.get("content-type", "") is_xlsx = "application/vnd.openxmlformats" in ct print(f" {'[OK]' if is_xlsx else '[FAIL]'} Content-Type: {ct}") print(f" [INFO] Error Excel downloaded ({len(r.content)} bytes)") else: print(" [SKIP] No batch_id from step 3") # ---- Step 5: 年份目标缺失拦截 ---- print("\n=== Step 5: Missing yearly target → 400 ===") excel_data = make_excel([ ["episode_number", "program_name", "air_date", "editor_name", "audience_share", "audience_rating", "is_rerun", "notes"], [200, "《军事科技》跨年测试", "2024-01-15", "张颖", 0.68, 3.2, "否", ""], ]) r, ok = step( "POST with 2024 air_date (no 2024 target) → 400", 400, "POST", f"{BASE}/api/imports/episodes", files={"file": ("test_2024.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, cookies=cookies, ) if ok: detail = r.json().get("detail", "") has_year_target = "年度目标" in detail and "2024" in detail print(f" {'[OK]' if has_year_target else '[FAIL]'} detail包含'年度目标'+'2024': {detail[:100]}") # ---- Step 6: 重复期次拦截 ---- print("\n=== Step 6: Duplicate episode_number ===") excel_data = make_excel([ ["episode_number", "program_name", "air_date", "editor_name", "audience_share", "audience_rating", "is_rerun", "notes"], [99, "《军事科技》重复期次", "2026-06-15", "张颖", 0.76, 3.9, "否", ""], ]) r, ok = step( "POST episode_number=99 (duplicate in same year) → 409 or row-level rejection", 200, "POST", f"{BASE}/api/imports/episodes", files={"file": ("test_duplicate.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, cookies=cookies, ) if ok: result = r.json() # 数据库唯一约束触发:整行被 reject,success=0, failed=1 has_db_error = result["failed_count"] == 1 and result["success_count"] == 0 has_unique_violation = any("UniqueViolation" in e.get("reason", "") or "idx_episodes_year_number" in e.get("reason", "") for e in result["errors"]) print(f" {'[OK]' if has_unique_violation else '[FAIL]'} 数据库唯一约束触发 (failed_count=1, UniqueViolation in reason)") # ---- Step 7: 未知编导软落地 ---- print("\n=== Step 7: Unknown editor soft-landing ===") excel_data = make_excel([ ["episode_number", "program_name", "air_date", "editor_name", "audience_share", "audience_rating", "is_rerun", "notes"], [301, "《军事科技》编导软引用测试", "2026-09-01", "李不存在", 0.69, 3.1, "否", ""], ]) r, ok = step( "POST with unknown editor_name='李不存在' → 200", 200, "POST", f"{BASE}/api/imports/episodes", files={"file": ("test_unknown_editor.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, cookies=cookies, ) if ok: result = r.json() ok = (result["success_count"] == 1 and result["failed_count"] == 0) print(f" {'[OK]' if ok else '[FAIL]'} success_count=1") # 验证 editor_id=null(通过列表接口找到刚导入的期次) r_list, ok_list = step( "GET /api/episodes?limit=100 → 确认 episode_number=301, editor_id=null, editor_name_snapshot='李不存在'", 200, "GET", f"{BASE}/api/episodes?limit=100", cookies=cookies, ) if ok_list: eps = r_list.json() ep_301 = next((e for e in eps if e.get("episode_number") == 301), None) if ep_301: editor_id_null = ep_301.get("editor_id") is None name_match = ep_301.get("editor_name_snapshot") == "李不存在" print(f" {'[OK]' if editor_id_null else '[FAIL]'} editor_id=null ({ep_301.get('editor_id')})") print(f" {'[OK]' if name_match else '[FAIL]'} editor_name_snapshot='李不存在' ({ep_301.get('editor_name_snapshot')})") else: print(f" [FAIL] episode_number=301 not found in list (import may have failed)") print("\n=== All steps done ===") print("NOTE: This script is辅助. 制片人仍需在 Swagger UI (http://localhost:8000/docs) 手动跑完 8 步确认全绿.") if __name__ == "__main__": main()