Files

238 lines
9.5 KiB
Python
Raw Permalink Normal View History

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