"""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)