Files
ctms/backend/scripts/smoke_setup_config.py
T
2026-05-25 14:35:24 +08:00

444 lines
20 KiB
Python

import asyncio
import base64
import json
import os
import sys
import urllib.error
import urllib.request
import asyncpg
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
BASE = os.getenv("BASE_URL", "http://localhost:8000")
ADMIN_EMAIL = os.getenv("EMAIL", "admin@huapont.cn")
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 encrypted_login(email: str, password: str) -> tuple[int, dict]:
status, login_key = request_json("/api/v1/auth/login-key")
if status != 200:
return status, login_key
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
plaintext = json.dumps(
{
"email": email,
"password": password,
"challenge": login_key["challenge"],
},
separators=(",", ":"),
).encode("utf-8")
aes_key = AESGCM.generate_key(bit_length=256)
iv = os.urandom(12)
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return request_json(
"/api/v1/auth/login",
method="POST",
payload={
"key_id": login_key["key_id"],
"challenge": login_key["challenge"],
"ciphertext": base64.b64encode(
json.dumps(
{
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"data": base64.b64encode(encrypted_data).decode("ascii"),
},
separators=(",", ":"),
).encode("utf-8")
).decode("ascii"),
},
)
def main() -> int:
print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}")
status, login = encrypted_login(ADMIN_EMAIL, 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") == "PUBLISHED"
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())