255 lines
11 KiB
Python
255 lines
11 KiB
Python
|
|
"""
|
|||
|
|
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()
|