238 lines
13 KiB
Python
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)
|