Files
ctms/backend/scripts/smoke_setup_config.py
T
2026-02-27 09:06:06 +08:00

400 lines
18 KiB
Python

import asyncio
import json
import os
import sys
import urllib.error
import urllib.request
import asyncpg
BASE = os.getenv("BASE_URL", "http://localhost:8000")
ADMIN_EMAIL = os.getenv("EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.getenv("PASSWORD", "admin123")
STUDY_ID = os.getenv("STUDY_ID", "").strip()
DATABASE_URL = (os.getenv("DATABASE_URL") or "postgresql://ctms_user:secret_password@db/ctms_db").replace("+asyncpg", "")
def request_json(path: str, *, method: str = "GET", token: str | None = None, payload: dict | None = None) -> tuple[int, dict]:
url = f"{BASE}{path}"
headers = {}
body = None
if token:
headers["Authorization"] = f"Bearer {token}"
if payload is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(payload).encode()
req = urllib.request.Request(url, method=method, headers=headers, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
text = resp.read().decode() or "{}"
return resp.getcode(), json.loads(text)
except urllib.error.HTTPError as exc:
text = exc.read().decode() or "{}"
try:
body_json = json.loads(text)
except Exception:
body_json = {"raw": text}
return exc.code, body_json
def assert_or_exit(condition: bool, message: str) -> None:
if not condition:
print(f"[FAIL] {message}")
sys.exit(1)
def extract_items(payload: dict) -> list[dict]:
if isinstance(payload, list):
return payload
items = payload.get("items")
if isinstance(items, list):
return items
return []
async def _db_fetch(sql: str, *args):
conn = await asyncpg.connect(DATABASE_URL)
try:
rows = await conn.fetch(sql, *args)
return [dict(row) for row in rows]
finally:
await conn.close()
def db_fetch(sql: str, *args) -> list[dict]:
return asyncio.run(_db_fetch(sql, *args))
def main() -> int:
print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}")
status, login = request_json(
"/api/v1/auth/login",
method="POST",
payload={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
)
assert_or_exit(status == 200, f"登录失败 status={status} body={login}")
token = login["access_token"]
if STUDY_ID:
study_id = STUDY_ID
else:
status, studies = request_json("/api/v1/studies/?skip=0&limit=1", token=token)
assert_or_exit(status == 200, f"获取项目列表失败 status={status} body={studies}")
items = studies.get("items") or []
assert_or_exit(bool(items), "未找到可用项目,请设置环境变量 STUDY_ID")
study_id = items[0]["id"]
print(f"[1/12] study_id={study_id}")
status, study_before = request_json(f"/api/v1/studies/{study_id}", token=token)
assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_before}")
status, sites_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token)
assert_or_exit(status == 200, f"获取中心列表失败 status={status} body={sites_resp}")
sites = extract_items(sites_resp if isinstance(sites_resp, dict) else {"items": sites_resp})
active_sites = [item for item in sites if item.get("is_active") is True]
assert_or_exit(len(active_sites) >= 1, "至少需要1个活跃中心以验证中心目标联动")
status, cfg = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token)
assert_or_exit(status == 200, f"获取配置失败 status={status} body={cfg}")
version = cfg["version"]
print(f"[2/12] get_ok version={version}")
payload = {"expected_version": version, "data": cfg["data"]}
payload["data"]["projectMilestones"] = [
{
"id": "smoke-project-ms-1",
"name": "立项启动",
"planDate": "2026-02-20",
"owner": ADMIN_EMAIL,
"remark": "project-ms-remark",
"status": "进行中",
}
]
payload["data"]["enrollmentPlan"]["monthlyGoalNote"] = f"smoke-test-note-v{version}"
payload["data"]["enrollmentPlan"]["stageBreakdown"] = f"smoke-stage-v{version}"
payload["data"]["enrollmentPlan"]["totalTarget"] = max(1, int(study_before.get("planned_enrollment_count") or 0) + 7)
payload["data"]["enrollmentPlan"]["startDate"] = "2026-03-01"
payload["data"]["enrollmentPlan"]["endDate"] = "2026-09-30"
payload["data"]["siteMilestones"] = [
{
"id": "smoke-site-ms-1",
"siteId": active_sites[0]["id"],
"siteName": active_sites[0].get("name") or "",
"milestone": "中心启动",
"planDate": "2026-03-05",
"owner": ADMIN_EMAIL,
"remark": "site-ms-remark",
}
]
payload["data"]["siteEnrollmentPlans"] = [
{
"id": f"smoke-plan-{idx+1}",
"siteId": site["id"],
"siteName": site.get("name") or "",
"target": 10 if idx == 0 else 8,
"startDate": "2026-03-01",
"endDate": "2026-09-30",
"note": "smoke-site-plan",
}
for idx, site in enumerate(active_sites[:2])
]
payload["data"]["monitoringStrategies"] = [
{
"id": "smoke-monitoring-1",
"strategyType": "风险监查",
"detail": "基于关键风险触发",
"frequency": "按触发",
"updatedAt": "",
"enabled": True,
}
]
payload["data"]["centerConfirm"] = [
{
"id": "smoke-center-confirm-1",
"siteId": active_sites[0]["id"],
"siteName": active_sites[0].get("name") or "",
"confirmer": "System Admin",
"confirmStatus": "已确认",
"confirmDate": "2026-03-15",
"note": "smoke-center-confirm",
}
]
targeted_site_ids = {item["siteId"] for item in payload["data"]["siteEnrollmentPlans"]}
before_site_map = {str(item.get("id")): item for item in sites if str(item.get("id")) in targeted_site_ids}
status, updated = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token, method="PUT", payload=payload)
assert_or_exit(status == 200, f"保存草稿失败 status={status} body={updated}")
latest_version = updated["version"]
print(f"[3/12] put_ok version={latest_version}")
status, study_after_save = request_json(f"/api/v1/studies/{study_id}", token=token)
assert_or_exit(status == 200, f"保存后获取项目详情失败 status={status} body={study_after_save}")
assert_or_exit(
study_after_save.get("planned_enrollment_count") == study_before.get("planned_enrollment_count")
and study_after_save.get("plan_start_date") == study_before.get("plan_start_date")
and study_after_save.get("plan_end_date") == study_before.get("plan_end_date")
and study_after_save.get("enrollment_monthly_goal_note") == study_before.get("enrollment_monthly_goal_note")
and study_after_save.get("enrollment_stage_breakdown") == study_before.get("enrollment_stage_breakdown"),
"保存草稿不应写入项目业务表字段",
)
status, sites_after_save_resp = request_json(
f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token
)
assert_or_exit(status == 200, f"保存后获取中心失败 status={status} body={sites_after_save_resp}")
sites_after_save = extract_items(sites_after_save_resp if isinstance(sites_after_save_resp, dict) else {"items": sites_after_save_resp})
after_save_site_map = {str(item.get("id")): item for item in sites_after_save if str(item.get("id")) in targeted_site_ids}
for site_id in targeted_site_ids:
before_item = before_site_map.get(site_id)
after_item = after_save_site_map.get(site_id)
if not before_item or not after_item:
continue
assert_or_exit(
after_item.get("enrollment_target") == before_item.get("enrollment_target")
and after_item.get("enrollment_plan_start_date") == before_item.get("enrollment_plan_start_date")
and after_item.get("enrollment_plan_end_date") == before_item.get("enrollment_plan_end_date")
and after_item.get("enrollment_plan_note") == before_item.get("enrollment_plan_note"),
f"保存草稿不应写入中心业务表字段 site={site_id}",
)
print("[4/12] save_no_projection_ok")
status, stale_result = request_json(
f"/api/v1/studies/{study_id}/setup-config",
token=token,
method="PUT",
payload={"expected_version": version, "data": updated["data"]},
)
stale_code = (stale_result.get("detail") or {}).get("code") if isinstance(stale_result.get("detail"), dict) else ""
assert_or_exit(status == 409 and stale_code == "SETUP_CONFIG_VERSION_CONFLICT", f"冲突校验失败 status={status} body={stale_result}")
print("[5/12] conflict_409_ok")
invalid_payload = json.loads(json.dumps(updated))
invalid_payload["expected_version"] = latest_version
invalid_payload["data"]["enrollmentPlan"]["startDate"] = "2026-12-31"
invalid_payload["data"]["enrollmentPlan"]["endDate"] = "2026-01-01"
status, invalid_result = request_json(
f"/api/v1/studies/{study_id}/setup-config",
token=token,
method="PUT",
payload={"expected_version": invalid_payload["expected_version"], "data": invalid_payload["data"]},
)
detail = invalid_result.get("detail") if isinstance(invalid_result, dict) else None
err_code = detail.get("code") if isinstance(detail, dict) else ""
err_list = detail.get("errors") if isinstance(detail, dict) else []
has_enrollment_date_error = any((item.get("field") == "enrollmentPlan.endDate") for item in (err_list or []))
assert_or_exit(status == 422 and err_code == "VALIDATION_ERROR" and has_enrollment_date_error, f"422校验失败 status={status} body={invalid_result}")
print("[6/12] validation_422_ok")
status, published = request_json(
f"/api/v1/studies/{study_id}/setup-config/publish",
token=token,
method="POST",
payload={"expected_version": latest_version},
)
assert_or_exit(status == 200 and published.get("publish_status") == "PUBLISHED", f"发布失败 status={status} body={published}")
projection_status = str(published.get("projection_status") or "").lower()
assert_or_exit(
projection_status in {"success", "partial_success"},
f"发布联动状态异常 projection_status={published.get('projection_status')} body={published}",
)
first_published_version = published["version"]
print(f"[7/12] first_publish_ok version={first_published_version}")
status, study_detail = request_json(f"/api/v1/studies/{study_id}", token=token)
assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_detail}")
assert_or_exit(
study_detail.get("planned_enrollment_count") == payload["data"]["enrollmentPlan"]["totalTarget"],
f"项目计划入组联动失败 expected={payload['data']['enrollmentPlan']['totalTarget']} actual={study_detail.get('planned_enrollment_count')}",
)
assert_or_exit(
study_detail.get("plan_start_date") == payload["data"]["enrollmentPlan"]["startDate"]
and study_detail.get("plan_end_date") == payload["data"]["enrollmentPlan"]["endDate"],
f"项目计划日期联动失败 expected=({payload['data']['enrollmentPlan']['startDate']},{payload['data']['enrollmentPlan']['endDate']}) actual=({study_detail.get('plan_start_date')},{study_detail.get('plan_end_date')})",
)
assert_or_exit(
study_detail.get("enrollment_monthly_goal_note") == payload["data"]["enrollmentPlan"]["monthlyGoalNote"]
and study_detail.get("enrollment_stage_breakdown") == payload["data"]["enrollmentPlan"]["stageBreakdown"],
"项目入组补充说明联动失败",
)
status, sites_after_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token)
assert_or_exit(status == 200, f"发布后获取中心失败 status={status} body={sites_after_resp}")
sites_after = extract_items(sites_after_resp if isinstance(sites_after_resp, dict) else {"items": sites_after_resp})
site_target_map = {str(item.get("id")): item.get("enrollment_target") for item in sites_after}
site_plan_start_map = {str(item.get("id")): item.get("enrollment_plan_start_date") for item in sites_after}
site_plan_end_map = {str(item.get("id")): item.get("enrollment_plan_end_date") for item in sites_after}
site_plan_note_map = {str(item.get("id")): item.get("enrollment_plan_note") for item in sites_after}
for plan in payload["data"]["siteEnrollmentPlans"]:
actual_target = site_target_map.get(str(plan["siteId"]))
assert_or_exit(
actual_target == plan["target"],
f"中心目标联动失败 site={plan['siteId']} expected={plan['target']} actual={actual_target}",
)
assert_or_exit(
site_plan_start_map.get(str(plan["siteId"])) == plan["startDate"]
and site_plan_end_map.get(str(plan["siteId"])) == plan["endDate"]
and site_plan_note_map.get(str(plan["siteId"])) == plan["note"],
f"中心入组计划附加字段联动失败 site={plan['siteId']}",
)
monitoring_rows = db_fetch(
"""
SELECT strategy_type, detail, frequency, enabled
FROM study_monitoring_strategies
WHERE study_id = $1::uuid
""",
study_id,
)
assert_or_exit(len(monitoring_rows) == 1, f"监查策略联动失败 rows={monitoring_rows}")
assert_or_exit(
monitoring_rows[0]["strategy_type"] == "风险监查"
and monitoring_rows[0]["detail"] == "基于关键风险触发"
and monitoring_rows[0]["frequency"] == "按触发"
and monitoring_rows[0]["enabled"] is True,
f"监查策略字段联动失败 rows={monitoring_rows}",
)
center_confirm_rows = db_fetch(
"""
SELECT site_id::text AS site_id, confirmer, confirm_status, confirm_date::text AS confirm_date, note
FROM study_center_confirms
WHERE study_id = $1::uuid
""",
study_id,
)
assert_or_exit(len(center_confirm_rows) == 1, f"中心确认联动失败 rows={center_confirm_rows}")
assert_or_exit(
center_confirm_rows[0]["site_id"] == active_sites[0]["id"]
and center_confirm_rows[0]["confirm_status"] == "已确认"
and center_confirm_rows[0]["confirm_date"] == "2026-03-15"
and center_confirm_rows[0]["note"] == "smoke-center-confirm",
f"中心确认字段联动失败 rows={center_confirm_rows}",
)
milestone_rows = db_fetch(
"""
SELECT type, name, owner_id::text AS owner_id, owner_name, notes
FROM milestones
WHERE study_id = $1::uuid
AND type IN ('SETUP_PROJECT_MILESTONE', 'SETUP_SITE_MILESTONE')
ORDER BY type, name
""",
study_id,
)
milestone_map = {(row["type"], row["name"]): row for row in milestone_rows}
project_row = milestone_map.get(("SETUP_PROJECT_MILESTONE", "立项启动"))
site_row = milestone_map.get(("SETUP_SITE_MILESTONE", "中心启动"))
assert_or_exit(project_row is not None and site_row is not None, f"里程碑联动失败 rows={milestone_rows}")
assert_or_exit(
project_row.get("owner_name") == ADMIN_EMAIL
and bool(project_row.get("owner_id"))
and project_row.get("notes") == "project-ms-remark",
f"项目里程碑owner结构化失败 row={project_row}",
)
assert_or_exit(
site_row.get("owner_name") == ADMIN_EMAIL
and bool(site_row.get("owner_id"))
and site_row.get("notes") == "site-ms-remark",
f"中心里程碑owner结构化失败 row={site_row}",
)
print("[8/12] publish_projection_db_assert_ok")
payload_v2 = {"expected_version": published["version"], "data": published["data"]}
payload_v2["data"]["enrollmentPlan"]["totalTarget"] = payload["data"]["enrollmentPlan"]["totalTarget"] + 3
payload_v2["data"]["enrollmentPlan"]["monthlyGoalNote"] = payload["data"]["enrollmentPlan"]["monthlyGoalNote"] + "-v2"
if payload_v2["data"]["siteEnrollmentPlans"]:
payload_v2["data"]["siteEnrollmentPlans"][0]["target"] = payload_v2["data"]["siteEnrollmentPlans"][0]["target"] + 2
status, updated_v2 = request_json(
f"/api/v1/studies/{study_id}/setup-config",
token=token,
method="PUT",
payload=payload_v2,
)
assert_or_exit(status == 200, f"二次保存草稿失败 status={status} body={updated_v2}")
status, published_v2 = request_json(
f"/api/v1/studies/{study_id}/setup-config/publish",
token=token,
method="POST",
payload={"expected_version": updated_v2["version"]},
)
assert_or_exit(status == 200 and published_v2.get("publish_status") == "PUBLISHED", f"二次发布失败 status={status} body={published_v2}")
status, rolled = request_json(
f"/api/v1/studies/{study_id}/setup-config/rollback",
token=token,
method="POST",
payload={"expected_version": published_v2["version"], "target_version": first_published_version},
)
assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}")
assert_or_exit(
rolled.get("publish_status") == "DRAFT"
and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"],
f"回滚结果异常 body={rolled}",
)
print("[9/12] rollback_ok")
status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={})
assert_or_exit(status == 200, f"锁定失败 status={status}")
try:
status, locked_result = request_json(
f"/api/v1/studies/{study_id}/setup-config",
token=token,
method="PUT",
payload={"expected_version": rolled["version"], "data": rolled["data"]},
)
assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}")
print("[10/10] lock_403_check_ok")
finally:
unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={})
assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}")
print("setup-config smoke test passed")
return 0
if __name__ == "__main__":
sys.exit(main())