228 lines
8.4 KiB
Python
228 lines
8.4 KiB
Python
|
|
import os
|
||
|
|
import sys
|
||
|
|
from decimal import Decimal
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///./storage/test_api_contract.db")
|
||
|
|
os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory")
|
||
|
|
os.environ.setdefault("LLM_MOCK_ENABLED", "true")
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
|
Path("storage").mkdir(exist_ok=True)
|
||
|
|
|
||
|
|
try:
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
except ImportError:
|
||
|
|
TestClient = None
|
||
|
|
|
||
|
|
|
||
|
|
def run_api_contract_tests() -> None:
|
||
|
|
"""API 合约:验证统一响应、user_id 校验、核心接口和跨用户隔离。"""
|
||
|
|
if TestClient is None:
|
||
|
|
print("api contract tests skipped: fastapi is not installed")
|
||
|
|
return
|
||
|
|
|
||
|
|
from app.main import app
|
||
|
|
from app.db.session import SessionLocal
|
||
|
|
from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TraditionalCase
|
||
|
|
from app.repositories.case_repository import CaseRepository
|
||
|
|
from scripts.init_demo_db import init_database
|
||
|
|
|
||
|
|
init_database()
|
||
|
|
client = TestClient(app)
|
||
|
|
headers = {"X-User-Id": "api_user_001", "X-Entry-Scene": "api_test"}
|
||
|
|
|
||
|
|
missing_user = client.get("/api/v1/agent/hello")
|
||
|
|
assert missing_user.status_code == 401
|
||
|
|
assert missing_user.json()["code"] == "USER_ID_REQUIRED"
|
||
|
|
|
||
|
|
hello = client.get("/api/v1/agent/hello", headers=headers)
|
||
|
|
assert hello.status_code == 200
|
||
|
|
assert hello.json()["code"] == "OK"
|
||
|
|
|
||
|
|
cases = client.get("/api/v1/cases", headers=headers)
|
||
|
|
assert cases.status_code == 200
|
||
|
|
case_id = cases.json()["data"]["items"][0]["id"]
|
||
|
|
|
||
|
|
created = client.post(
|
||
|
|
"/api/v1/sessions",
|
||
|
|
headers=headers,
|
||
|
|
json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"},
|
||
|
|
)
|
||
|
|
assert created.status_code == 200
|
||
|
|
session_id = created.json()["data"]["session_id"]
|
||
|
|
|
||
|
|
cross_user = client.get(f"/api/v1/sessions/{session_id}/order-items", headers={"X-User-Id": "api_user_002"})
|
||
|
|
assert cross_user.status_code == 404
|
||
|
|
assert cross_user.json()["code"] == "SESSION_NOT_FOUND"
|
||
|
|
|
||
|
|
invalid_order = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "bad_item"})
|
||
|
|
assert invalid_order.status_code == 404
|
||
|
|
assert invalid_order.json()["code"] == "ORDER_ITEM_NOT_FOUND"
|
||
|
|
|
||
|
|
order_one = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "blood_routine"})
|
||
|
|
assert order_one.status_code == 200
|
||
|
|
assert order_one.json()["data"]["already_ordered"] is False
|
||
|
|
order_two = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "blood_routine"})
|
||
|
|
assert order_two.status_code == 200
|
||
|
|
assert order_two.json()["data"]["already_ordered"] is True
|
||
|
|
|
||
|
|
practice_hint_session = client.post(
|
||
|
|
"/api/v1/sessions",
|
||
|
|
headers=headers,
|
||
|
|
json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"},
|
||
|
|
)
|
||
|
|
assert practice_hint_session.status_code == 200
|
||
|
|
practice_hint_session_id = practice_hint_session.json()["data"]["session_id"]
|
||
|
|
hint = client.post(
|
||
|
|
f"/api/v1/sessions/{practice_hint_session_id}/hints",
|
||
|
|
headers=headers,
|
||
|
|
json={"scope": "current_conversation"},
|
||
|
|
)
|
||
|
|
assert hint.status_code == 200
|
||
|
|
assert hint.json()["data"]["hints"]
|
||
|
|
assert "recommended_orders" in hint.json()["data"]
|
||
|
|
|
||
|
|
teaching = client.post(
|
||
|
|
"/api/v1/sessions",
|
||
|
|
headers=headers,
|
||
|
|
json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "teaching", "score_type": "percentage"},
|
||
|
|
)
|
||
|
|
assert teaching.status_code == 200
|
||
|
|
teaching_hint = client.post(
|
||
|
|
f"/api/v1/sessions/{teaching.json()['data']['session_id']}/hints",
|
||
|
|
headers=headers,
|
||
|
|
json={"scope": "current_conversation"},
|
||
|
|
)
|
||
|
|
assert teaching_hint.status_code == 400
|
||
|
|
assert teaching_hint.json()["code"] == "SESSION_STATUS_INVALID"
|
||
|
|
|
||
|
|
llm_fast = client.post("/api/v1/llm/test/deepseek-fast", headers=headers, json={"message": "hello"})
|
||
|
|
assert llm_fast.status_code == 200
|
||
|
|
assert llm_fast.json()["code"] == "OK"
|
||
|
|
assert llm_fast.json()["data"]["stream"] is False
|
||
|
|
|
||
|
|
llm_reason = client.post("/api/v1/llm/test/deepseek-reason", headers=headers, json={"message": "hello"})
|
||
|
|
assert llm_reason.status_code == 200
|
||
|
|
assert llm_reason.json()["code"] == "OK"
|
||
|
|
assert "total_latency_ms" in llm_reason.json()["data"]
|
||
|
|
|
||
|
|
import_preview = client.post(
|
||
|
|
"/api/v1/imports/case-sql/preview",
|
||
|
|
headers=headers,
|
||
|
|
files={"file": ("bad.sql", b"not sql", "application/sql")},
|
||
|
|
)
|
||
|
|
assert import_preview.status_code == 200
|
||
|
|
assert import_preview.json()["code"] == "OK"
|
||
|
|
assert import_preview.json()["data"]["can_import"] is False
|
||
|
|
assert import_preview.json()["data"]["errors"]
|
||
|
|
|
||
|
|
import_apply = client.post(
|
||
|
|
"/api/v1/imports/case-sql/apply",
|
||
|
|
headers=headers,
|
||
|
|
files={"file": ("bad.sql", b"not sql", "application/sql")},
|
||
|
|
)
|
||
|
|
assert import_apply.status_code == 400
|
||
|
|
assert import_apply.json()["code"] == "CASE_SQL_IMPORT_INVALID"
|
||
|
|
|
||
|
|
temp_case_id = 880001
|
||
|
|
with SessionLocal() as db:
|
||
|
|
CaseRepository(db).delete_case_cascade(temp_case_id)
|
||
|
|
db.add(
|
||
|
|
CaseBase(
|
||
|
|
id=temp_case_id,
|
||
|
|
title="删除测试病例",
|
||
|
|
case_type="diagnosis_treatment",
|
||
|
|
difficulty="medium",
|
||
|
|
chief_complaint="发热、咳嗽",
|
||
|
|
description="用于删除接口测试的临时病例",
|
||
|
|
patient_age=6,
|
||
|
|
patient_gender="male",
|
||
|
|
tags="test",
|
||
|
|
symptom_tags=[],
|
||
|
|
disease_tags=[],
|
||
|
|
competency_tags=[],
|
||
|
|
guideline_tags=[],
|
||
|
|
knowledge_points=[],
|
||
|
|
icd_codes="",
|
||
|
|
osce_enabled=False,
|
||
|
|
rag_enabled=False,
|
||
|
|
ai_prompt_template="",
|
||
|
|
multimodal_assets=[],
|
||
|
|
vector_status=0,
|
||
|
|
publish_status=1,
|
||
|
|
status=1,
|
||
|
|
department_id=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db.add(
|
||
|
|
TraditionalCase(
|
||
|
|
id=temp_case_id,
|
||
|
|
case_id=temp_case_id,
|
||
|
|
standard_diagnosis="测试诊断",
|
||
|
|
standard_treatment="测试治疗",
|
||
|
|
guideline_reference="测试指南",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db.add(
|
||
|
|
ScoringRule(
|
||
|
|
id=temp_case_id,
|
||
|
|
case_id=temp_case_id,
|
||
|
|
dimension="信息采集",
|
||
|
|
competency_dimension="问诊完整性",
|
||
|
|
score_weight=Decimal("10.00"),
|
||
|
|
ai_auto_score=True,
|
||
|
|
osce_dimension=False,
|
||
|
|
scoring_standard="测试评分标准",
|
||
|
|
rubric_json={},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db.add(
|
||
|
|
CaseExamItem(
|
||
|
|
id=temp_case_id,
|
||
|
|
case_id=temp_case_id,
|
||
|
|
item_code="temp_exam",
|
||
|
|
item_name="测试检查",
|
||
|
|
item_type="lab",
|
||
|
|
result_text="测试结果",
|
||
|
|
result_structured={},
|
||
|
|
is_key=True,
|
||
|
|
is_abnormal=False,
|
||
|
|
score_weight=Decimal("1.00"),
|
||
|
|
display_order=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
delete_preview = client.get(f"/api/v1/cases/{temp_case_id}/delete-preview", headers=headers)
|
||
|
|
assert delete_preview.status_code == 200
|
||
|
|
assert delete_preview.json()["data"]["affected"]["case_base"] == 1
|
||
|
|
assert delete_preview.json()["data"]["affected"]["case_exam_item"] == 1
|
||
|
|
|
||
|
|
delete_without_confirm = client.request(
|
||
|
|
"DELETE",
|
||
|
|
f"/api/v1/cases/{temp_case_id}",
|
||
|
|
headers=headers,
|
||
|
|
json={"confirm": False, "delete_training_data": True},
|
||
|
|
)
|
||
|
|
assert delete_without_confirm.status_code == 400
|
||
|
|
assert delete_without_confirm.json()["code"] == "CASE_DELETE_CONFIRM_REQUIRED"
|
||
|
|
|
||
|
|
delete_case = client.request(
|
||
|
|
"DELETE",
|
||
|
|
f"/api/v1/cases/{temp_case_id}",
|
||
|
|
headers=headers,
|
||
|
|
json={"confirm": True, "delete_training_data": True},
|
||
|
|
)
|
||
|
|
assert delete_case.status_code == 200
|
||
|
|
assert delete_case.json()["data"]["deleted_counts"]["case_base"] == 1
|
||
|
|
|
||
|
|
deleted_detail = client.get(f"/api/v1/cases/{temp_case_id}", headers=headers)
|
||
|
|
assert deleted_detail.status_code == 404
|
||
|
|
assert deleted_detail.json()["code"] == "CASE_NOT_FOUND"
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
run_api_contract_tests()
|
||
|
|
print("api contract tests passed")
|