2026-05-29 15:58:00 +08:00
|
|
|
"""病例域负向测试:字段校验、越权、限流、事务回滚、AI Schema 违规。"""
|
|
|
|
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from django.core.cache import cache
|
|
|
|
|
from django.db import IntegrityError
|
|
|
|
|
from rest_framework.test import APIClient
|
|
|
|
|
|
|
|
|
|
from apps.case.models import CaseBase, ScoringRule
|
|
|
|
|
from apps.user.throttling import PdfParseUserThrottle
|
|
|
|
|
from config.exceptions import AppError
|
|
|
|
|
from .conftest import (
|
|
|
|
|
CacheTestCase, CacheTransactionTestCase,
|
|
|
|
|
CASE_PARSE_URL, CASE_FULL_CREATE_URL,
|
|
|
|
|
case_full_url, create_test_user, get_auth_client, ensure_department,
|
|
|
|
|
build_traditional_payload, make_fake_pdf,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseFieldValidationTest(CacheTestCase):
|
|
|
|
|
"""病例字段校验负向测试。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.user = create_test_user(phone='13800002001', password='CaseNeg1')
|
|
|
|
|
self.client = get_auth_client(self.user)
|
|
|
|
|
ensure_department('儿科')
|
|
|
|
|
|
|
|
|
|
def test_invalid_case_type_400(self):
|
|
|
|
|
"""N10: case_type='invalid' → 400 CASE_TYPE_NOT_SUPPORTED"""
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
payload['case_type'] = 'invalid'
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 400, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_TYPE_NOT_SUPPORTED')
|
|
|
|
|
|
|
|
|
|
def test_empty_scoring_rules_400(self):
|
|
|
|
|
"""N11: scoring_rules=[] → 400 CASE_VALIDATION_ERROR"""
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
payload['scoring_rules'] = []
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 400, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_VALIDATION_ERROR')
|
|
|
|
|
|
|
|
|
|
def test_subtable_conflict_400(self):
|
|
|
|
|
"""N12: 同时传 traditional + teaching → 400 CASE_SUBTYPE_CONFLICT"""
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
payload['teaching'] = {
|
|
|
|
|
'teaching_goal': '不应该出现',
|
|
|
|
|
'discussion_questions': '冲突',
|
|
|
|
|
}
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 400, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_SUBTYPE_CONFLICT')
|
|
|
|
|
|
2026-06-03 17:34:47 +08:00
|
|
|
def test_duplicate_exam_items_deduped_on_create(self):
|
|
|
|
|
"""同一病例重复 item_code:归一化后只保留一条并成功创建。"""
|
|
|
|
|
payload = build_traditional_payload(with_exam_items=True)
|
|
|
|
|
self.assertIn('exam_items', payload)
|
|
|
|
|
payload['exam_items'].append({
|
|
|
|
|
**payload['exam_items'][0],
|
|
|
|
|
'item_name': '重复血常规',
|
|
|
|
|
'result_text': '应被忽略',
|
|
|
|
|
})
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 201, resp.content)
|
|
|
|
|
self.assertEqual(len(resp.json()['exam_items']), 1)
|
|
|
|
|
|
2026-05-29 15:58:00 +08:00
|
|
|
def test_missing_subtable_400(self):
|
|
|
|
|
"""N13: case_type=traditional 但无 traditional 子表 → 400 CASE_SUBTYPE_REQUIRED"""
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
del payload['traditional']
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 400, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_SUBTYPE_REQUIRED')
|
|
|
|
|
|
|
|
|
|
def test_patch_published_case_400(self):
|
|
|
|
|
"""N16: PATCH 已发布病例 → 400 CASE_NOT_EDITABLE"""
|
|
|
|
|
# 先创建病例
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 201, resp.content)
|
|
|
|
|
case_id = resp.json()['case']['id']
|
|
|
|
|
|
|
|
|
|
# 发布
|
|
|
|
|
case = CaseBase.objects.get(id=case_id)
|
|
|
|
|
case.publish_status = 1
|
|
|
|
|
case.save(update_fields=['publish_status'])
|
|
|
|
|
|
|
|
|
|
# PATCH → 应被拒绝
|
|
|
|
|
resp = self.client.patch(case_full_url(case_id), {
|
|
|
|
|
'title': '不应该成功',
|
|
|
|
|
}, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 400, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_NOT_EDITABLE')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseAuthorizationTest(CacheTestCase):
|
|
|
|
|
"""病例越权测试。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
ensure_department('儿科')
|
|
|
|
|
|
|
|
|
|
def test_unauth_full_create_401(self):
|
|
|
|
|
"""N14: 未登录 POST full-create → 401"""
|
|
|
|
|
client = APIClient()
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
resp = client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 401, resp.content)
|
|
|
|
|
|
|
|
|
|
def test_view_other_draft_403(self):
|
|
|
|
|
"""N15: 用户 B 看用户 A 的草稿 → 403"""
|
|
|
|
|
# 用户 A 创建草稿
|
|
|
|
|
user_a = create_test_user(phone='13800002010', password='UserAPass1')
|
|
|
|
|
client_a = get_auth_client(user_a)
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
resp = client_a.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
self.assertEqual(resp.status_code, 201, resp.content)
|
|
|
|
|
case_id = resp.json()['case']['id']
|
|
|
|
|
|
|
|
|
|
# 验证是草稿 (publish_status=0)
|
|
|
|
|
self.assertEqual(resp.json()['case']['publish_status'], 0)
|
|
|
|
|
|
|
|
|
|
# 用户 B 尝试访问
|
|
|
|
|
user_b = create_test_user(phone='13800002011', password='UserBPass1')
|
|
|
|
|
client_b = get_auth_client(user_b)
|
|
|
|
|
resp = client_b.get(case_full_url(case_id))
|
|
|
|
|
self.assertEqual(resp.status_code, 403, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'CASE_PERMISSION_DENIED')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseRateLimitTest(CacheTestCase):
|
|
|
|
|
"""病例限流测试。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.user = create_test_user(phone='13800002020', password='RateTest1')
|
|
|
|
|
self.client = get_auth_client(self.user)
|
|
|
|
|
|
|
|
|
|
def test_rate_limit_pdf_parse_429(self):
|
|
|
|
|
"""N17: PDF 解析限流 → 429"""
|
|
|
|
|
with (
|
|
|
|
|
patch.object(PdfParseUserThrottle, 'allow_request', return_value=False),
|
|
|
|
|
patch.object(PdfParseUserThrottle, 'wait', return_value=60),
|
|
|
|
|
):
|
|
|
|
|
fake_pdf = make_fake_pdf()
|
|
|
|
|
resp = self.client.post(
|
|
|
|
|
CASE_PARSE_URL,
|
|
|
|
|
{'files': fake_pdf, 'case_type': 'traditional'},
|
|
|
|
|
format='multipart',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(resp.status_code, 429, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'SYS_RATE_LIMIT')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseTransactionRollbackTest(CacheTransactionTestCase):
|
|
|
|
|
"""N18: 事务回滚测试 — 必须用 TransactionTestCase。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.user = create_test_user(phone='13800002030', password='TxTest1')
|
|
|
|
|
self.client = get_auth_client(self.user)
|
|
|
|
|
ensure_department('儿科')
|
|
|
|
|
|
|
|
|
|
def test_transaction_rollback(self):
|
|
|
|
|
"""N18: bulk_create 失败 → CaseBase 应回滚"""
|
|
|
|
|
initial_count = CaseBase.objects.count()
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
ScoringRule.objects, 'bulk_create',
|
|
|
|
|
side_effect=IntegrityError('mocked DB error'),
|
|
|
|
|
):
|
|
|
|
|
payload = build_traditional_payload()
|
|
|
|
|
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
|
|
|
|
|
|
|
|
|
|
# 请求应失败(500 或被异常处理捕获)
|
|
|
|
|
self.assertGreaterEqual(resp.status_code, 400)
|
|
|
|
|
|
|
|
|
|
# CaseBase 应回滚到初始状态
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
CaseBase.objects.count(), initial_count,
|
|
|
|
|
'CaseBase 应因事务回滚而未增加',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseAISchemaTest(CacheTestCase):
|
|
|
|
|
"""AI Schema 违规测试。"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
self.user = create_test_user(phone='13800002040', password='AITest1')
|
|
|
|
|
self.client = get_auth_client(self.user)
|
|
|
|
|
|
|
|
|
|
@patch('apps.case.services.case_importer.extract_text_from_pdfs',
|
|
|
|
|
return_value='虚拟文本内容')
|
|
|
|
|
def test_ai_bad_json_500(self, mock_pdf):
|
|
|
|
|
"""N19: DeepSeek 返回非法 JSON → 500 AI_BAD_JSON"""
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
|
|
|
|
'apps.case.services.deepseek_client.call_deepseek',
|
|
|
|
|
side_effect=AppError('AI_BAD_JSON', 'AI 返回非合法 JSON', status_code=500),
|
|
|
|
|
),
|
|
|
|
|
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
|
|
|
|
|
):
|
|
|
|
|
fake_pdf = make_fake_pdf()
|
|
|
|
|
resp = self.client.post(
|
|
|
|
|
CASE_PARSE_URL,
|
|
|
|
|
{'files': fake_pdf, 'case_type': 'traditional'},
|
|
|
|
|
format='multipart',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(resp.status_code, 500, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'AI_BAD_JSON')
|
|
|
|
|
|
|
|
|
|
@patch('apps.case.services.case_importer.extract_text_from_pdfs',
|
|
|
|
|
return_value='虚拟文本内容')
|
|
|
|
|
def test_ai_schema_violation_500(self, mock_pdf):
|
|
|
|
|
"""N20: DeepSeek 输出不符合 JSON Schema → 500 AI_SCHEMA_VIOLATION"""
|
|
|
|
|
# 返回缺少 title 和 case_type 的数据 → jsonschema 校验失败
|
|
|
|
|
bad_result = {
|
|
|
|
|
'data': {
|
|
|
|
|
'wrong_field': 'no title here',
|
|
|
|
|
},
|
|
|
|
|
'usage': {'prompt_tokens': 10, 'completion_tokens': 20},
|
|
|
|
|
}
|
|
|
|
|
with (
|
|
|
|
|
patch('apps.case.services.deepseek_client.call_deepseek', return_value=bad_result),
|
|
|
|
|
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
|
|
|
|
|
):
|
|
|
|
|
fake_pdf = make_fake_pdf()
|
|
|
|
|
resp = self.client.post(
|
|
|
|
|
CASE_PARSE_URL,
|
|
|
|
|
{'files': fake_pdf, 'case_type': 'traditional'},
|
|
|
|
|
format='multipart',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(resp.status_code, 500, resp.content)
|
|
|
|
|
self.assertEqual(resp.json()['code'], 'AI_SCHEMA_VIOLATION')
|