Files
medical_training/test/test_cms_case.py
T
2026-06-12 17:19:23 +08:00

238 lines
13 KiB
Python

"""CMS 病例库 + AI 病例生成 + 病例审核测试(超管 / 内容管理员 / 医院管理员)。
覆盖 CMS-CASE-1~7 + CMS-CASE-AI-1 + CMS-AUDIT-3(发布)、权限与角色分工、机构(institution)
范围收口、软删除(is_deleted)、状态机(草稿0→正常1→已发布2)、仅 GET/POST。AI 调用全程 mock。
"""
from unittest.mock import patch
from rest_framework.test import APIClient
from apps.case.models import CaseBase
from apps.user.throttling import PdfParseUserThrottle
from .conftest import (
CacheTestCase, create_test_user, get_auth_client, ensure_department, ensure_institution,
build_traditional_payload, make_fake_pdf, MOCK_C1_PARSE_RESULT,
)
CASE_URL = '/api/cms/cases/'
IMPORT_PDF_URL = '/api/cms/cases/import-pdf/'
AI_GENERATE_URL = '/api/cms/cases/ai-generate/'
def full_url(pk):
return f'/api/cms/cases/{pk}/full/'
def relations_url(pk):
return f'/api/cms/cases/{pk}/relations/'
def submit_url(pk):
return f'/api/cms/cases/{pk}/submit/'
def disable_url(pk):
return f'/api/cms/cases/{pk}/disable/'
def publish_url(pk):
return f'/api/cms/cases/{pk}/publish/'
class CmsCaseTest(CacheTestCase):
def setUp(self):
super().setUp()
self.instA = ensure_institution(name='测试医院A', code='CASE-HA')
self.instB = ensure_institution(name='测试医院B', code='CASE-HB')
self.dept = ensure_department('儿科')
self.dept2 = ensure_department('内科')
self.superu = create_test_user(phone='13966600001', role_type='super_admin', institution=self.instA)
self.content = create_test_user(phone='13966600002', role_type='content_admin', institution=self.instA)
self.contentB = create_test_user(phone='13966600004', role_type='content_admin', institution=self.instB)
self.hospital = create_test_user(phone='13966600005', role_type='hospital_admin', institution=self.instA)
self.student = create_test_user(phone='13966600003', role_type='student', institution=self.instA)
self.sclient = get_auth_client(self.superu)
self.cclient = get_auth_client(self.content)
self.hclient = get_auth_client(self.hospital)
def _create_case(self, client, title='CMS测试病例', institution_id=None):
payload = build_traditional_payload(department_name='儿科', scoring_rules_count=2, with_exam_items=True)
payload['title'] = title
if institution_id is not None:
payload['institution_id'] = institution_id
resp = client.post(CASE_URL, payload, format='json')
self.assertEqual(resp.status_code, 201, resp.content)
return resp.json()
# ── 权限 ─────────────────────────────────────────────────────────────
def test_requires_auth(self):
self.assertEqual(APIClient().get(CASE_URL).status_code, 401)
def test_student_forbidden(self):
resp = get_auth_client(self.student).get(CASE_URL)
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED')
# ── CMS-CASE-3 表单新增 ──────────────────────────────────────────────
def test_create_form(self):
created = self._create_case(self.sclient)
self.assertEqual(created['case']['case_type'], 'traditional')
self.assertEqual(created['case']['publish_status'], 0) # 草稿
self.assertEqual(created['case']['created_by'], self.superu.id)
self.assertEqual(created['case']['institution'], self.instA.id) # 超管缺省落本院
self.assertEqual(len(created['scoring_rules']), 2)
self.assertEqual(len(created['exam_items']), 1)
def test_content_admin_create_forces_own_institution(self):
# 内容管理员即便传 institution_id 也强制落本院
created = self._create_case(self.cclient, institution_id=self.instB.id)
self.assertEqual(created['case']['institution'], self.instA.id)
def test_create_missing_scoring_rules_400(self):
payload = build_traditional_payload(department_name='儿科')
payload.pop('scoring_rules')
resp = self.sclient.post(CASE_URL, payload, format='json')
self.assertEqual(resp.status_code, 400, resp.content)
def test_hospital_admin_cannot_create_403(self):
payload = build_traditional_payload(department_name='儿科')
resp = self.hclient.post(CASE_URL, payload, format='json')
self.assertEqual(resp.status_code, 403, resp.content)
# ── CMS-CASE-1 列表 + 机构范围 ───────────────────────────────────────
def test_list_scope_by_institution(self):
self._create_case(self.sclient, title='A院病例') # 超管落 instA
self._create_case(self.sclient, title='B院病例', institution_id=self.instB.id)
self.assertEqual(self.sclient.get(CASE_URL).json()['count'], 2) # 超管全部
c_list = self.cclient.get(CASE_URL).json() # instA 内容管理员
self.assertEqual(c_list['count'], 1)
self.assertEqual(c_list['results'][0]['title'], 'A院病例')
cb_list = get_auth_client(self.contentB).get(CASE_URL).json() # instB 内容管理员
self.assertEqual(cb_list['count'], 1)
self.assertEqual(cb_list['results'][0]['title'], 'B院病例')
# ── CMS-CASE-7 病例查看 + 跨机构 404 ─────────────────────────────────
def test_full_view(self):
cid = self._create_case(self.sclient)['case']['id']
resp = self.sclient.get(full_url(cid))
self.assertEqual(resp.status_code, 200, resp.content)
self.assertEqual(resp.json()['case']['id'], cid)
self.assertEqual(resp.json()['case']['institution_name'], '测试医院A')
def test_content_admin_cannot_touch_other_institution_404(self):
cid = self._create_case(self.sclient, institution_id=self.instB.id)['case']['id']
self.assertEqual(self.cclient.get(full_url(cid)).status_code, 404)
self.assertEqual(self.cclient.post(disable_url(cid)).status_code, 404)
# ── CMS-CASE-4 编辑关联(改机构 + 科室)──────────────────────────────
def test_relations_change_institution_and_department(self):
cid = self._create_case(self.sclient)['case']['id']
resp = self.sclient.post(relations_url(cid),
{'institution_id': self.instB.id, 'department_id': self.dept2.id})
self.assertEqual(resp.status_code, 200, resp.content)
self.assertEqual(resp.json()['institution_name'], '测试医院B')
self.assertEqual(resp.json()['department_name'], '内科')
case = CaseBase.objects.get(id=cid)
self.assertEqual(case.institution_id, self.instB.id)
self.assertEqual(case.department_id, self.dept2.id)
def test_relations_bad_institution_400(self):
cid = self._create_case(self.sclient)['case']['id']
resp = self.sclient.post(relations_url(cid), {'institution_id': 999999})
self.assertEqual(resp.status_code, 400, resp.content)
# ── CMS-CASE-5 提交(草稿 → 正常)────────────────────────────────────
def test_submit_draft_to_normal(self):
cid = self._create_case(self.cclient)['case']['id']
resp = self.cclient.post(submit_url(cid))
self.assertEqual(resp.status_code, 200, resp.content)
self.assertEqual(resp.json()['publish_status'], 1) # 正常
self.assertEqual(self.cclient.post(submit_url(cid)).status_code, 400) # 非草稿重复提交
# ── CMS-CASE-6 停用(软删除)─────────────────────────────────────────
def test_disable_soft_delete(self):
cid = self._create_case(self.cclient)['case']['id']
resp = self.cclient.post(disable_url(cid))
self.assertEqual(resp.status_code, 200, resp.content)
self.assertFalse(CaseBase.objects.filter(id=cid).exists()) # 默认管理器过滤
self.assertTrue(CaseBase.all_objects.get(id=cid).is_deleted) # 实际软删
self.assertEqual(self.cclient.get(CASE_URL).json()['count'], 0) # 列表不再返回
# ── CMS-AUDIT-3 发布(正常 → 已发布,医院管理员)─────────────────────
def test_publish_by_hospital_admin(self):
cid = self._create_case(self.cclient)['case']['id']
self.cclient.post(submit_url(cid)) # 草稿→正常
resp = self.hclient.post(publish_url(cid)) # 医院管理员发布
self.assertEqual(resp.status_code, 200, resp.content)
self.assertEqual(resp.json()['publish_status'], 2) # 已发布
self.assertEqual(CaseBase.objects.get(id=cid).publish_status, 2)
def test_publish_requires_normal_status_400(self):
cid = self._create_case(self.cclient)['case']['id'] # 仍是草稿(0)
self.assertEqual(self.hclient.post(publish_url(cid)).status_code, 400)
def test_content_admin_cannot_publish_403(self):
cid = self._create_case(self.cclient)['case']['id']
self.cclient.post(submit_url(cid))
self.assertEqual(self.cclient.post(publish_url(cid)).status_code, 403)
def test_super_admin_cannot_publish_403(self):
# 超级管理员不做病例审核发布
cid = self._create_case(self.cclient)['case']['id']
self.cclient.post(submit_url(cid))
self.assertEqual(self.sclient.post(publish_url(cid)).status_code, 403)
def test_audit_list_filter_normal(self):
cid = self._create_case(self.cclient)['case']['id']
self.cclient.post(submit_url(cid))
audit = self.hclient.get(CASE_URL, {'publish_status': 1}).json() # 待审核(正常)
self.assertEqual(audit['count'], 1)
self.assertEqual(audit['results'][0]['id'], cid)
# ── 方法收敛:PATCH/DELETE → 405 ─────────────────────────────────────
def test_patch_delete_not_allowed(self):
cid = self._create_case(self.sclient)['case']['id']
self.assertEqual(self.sclient.patch(f'{CASE_URL}{cid}/', {'title': 'x'}).status_code, 405)
self.assertEqual(self.sclient.delete(f'{CASE_URL}{cid}/').status_code, 405)
# ── CMS-CASE-2 PDF 导入(mock AI)────────────────────────────────────
@patch('apps.case.services.case_importer.extract_text_from_pdfs',
return_value='患儿,男,4岁,发热3天。')
def test_import_pdf_preview(self, _mock_pdf):
with (
patch('apps.case.services.deepseek_client.call_deepseek', return_value=MOCK_C1_PARSE_RESULT),
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
):
resp = self.sclient.post(IMPORT_PDF_URL,
{'files': make_fake_pdf(), 'case_type': 'traditional'},
format='multipart')
self.assertEqual(resp.status_code, 200, resp.content)
self.assertIn('parse_id', resp.json())
self.assertEqual(CaseBase.objects.count(), 0) # 仅预览,不落库
def test_hospital_admin_cannot_import_or_ai_403(self):
with patch.object(PdfParseUserThrottle, 'allow_request', return_value=True):
self.assertEqual(
self.hclient.post(IMPORT_PDF_URL, {'case_type': 'traditional'}, format='multipart').status_code, 403)
self.assertEqual(
self.hclient.post(AI_GENERATE_URL, {'prompt': 'x', 'case_type': 'traditional'}, format='json').status_code, 403)
# ── CMS-CASE-AI-1 AI 生成(mock AI)──────────────────────────────────
def test_ai_generate(self):
with (
patch('apps.case.services.deepseek_client.call_deepseek', return_value=MOCK_C1_PARSE_RESULT),
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
):
resp = self.cclient.post(AI_GENERATE_URL,
{'prompt': '生成一个儿科发热病例', 'case_type': 'traditional'},
format='json')
self.assertEqual(resp.status_code, 200, resp.content)
self.assertIn('parse_id', resp.json())
self.assertEqual(CaseBase.objects.count(), 0) # 不落库
def test_ai_generate_missing_prompt_400(self):
with patch.object(PdfParseUserThrottle, 'allow_request', return_value=True):
resp = self.cclient.post(AI_GENERATE_URL, {'case_type': 'traditional'}, format='json')
self.assertEqual(resp.status_code, 400, resp.content)