feat: add trusted ticket intake backend
This commit is contained in:
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal file
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
|
||||
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.modelhub.models import ModelDeployment, ModelProvider
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
from apps.tickets.draft_services import TicketDraftError, add_answer
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketDraft, TicketDraftMessage
|
||||
from apps.tickets.tasks import cleanup_expired_ticket_drafts
|
||||
|
||||
|
||||
def ticket_fields(**overrides):
|
||||
fields = {
|
||||
"title": "生产采集任务未更新",
|
||||
"description": "生产环境采集任务已两小时没有更新",
|
||||
"category_code": "technical_issue",
|
||||
"priority": "high",
|
||||
"problem_performance": "采集进度停滞",
|
||||
"problem_object": "采集任务 A",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "华东分公司",
|
||||
"impact_scope": "单个任务",
|
||||
"environment": "生产环境",
|
||||
"related_system": "数据中台",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return fields
|
||||
|
||||
|
||||
def create_ticket_agent(*, capability="text_chat", approved=False):
|
||||
provider = ModelProvider.objects.create(
|
||||
name="建单渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
image_egress_approved=approved,
|
||||
)
|
||||
deployment = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="建单模型",
|
||||
model_name="ticket-model",
|
||||
capability=capability,
|
||||
)
|
||||
skill = SkillDefinition.objects.create(code="ticket-draft", name="建单")
|
||||
skill_version = SkillVersion.objects.create(
|
||||
definition=skill,
|
||||
version=1,
|
||||
status="published",
|
||||
prompt_template="{{ input.source_text }}",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"source_text": {"type": "string"}},
|
||||
"required": ["source_text"],
|
||||
},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
agent = AgentDefinition.objects.create(code="ticket-intake", name="建单 Agent")
|
||||
version = AgentVersion.objects.create(
|
||||
definition=agent,
|
||||
version=1,
|
||||
status="published",
|
||||
model_deployment=deployment,
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
AgentStepBinding.objects.create(
|
||||
agent_version=version,
|
||||
code="draft",
|
||||
order=1,
|
||||
skill_version=skill_version,
|
||||
input_mapping={"source_text": "$input.source_text"},
|
||||
)
|
||||
agent.active_version = version
|
||||
agent.save(update_fields=["active_version"])
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def category(db):
|
||||
return TicketCategory.objects.get(code="technical_issue")
|
||||
|
||||
|
||||
def test_feature_requires_published_ticket_agent(admin_client, db):
|
||||
response = admin_client.get("/api/ticket-drafts/availability")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["available"] is False
|
||||
create_ticket_agent()
|
||||
response = admin_client.get("/api/ticket-drafts/availability")
|
||||
assert response.json()["available"] is True
|
||||
assert response.json()["vision_available"] is False
|
||||
|
||||
|
||||
def test_revision_conflict_and_manual_edit(admin_client, db):
|
||||
create_ticket_agent()
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "任务未更新"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/ticket-drafts/{created['public_id']}",
|
||||
data={"expected_revision": created["revision"], "fields": ticket_fields()},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["revision"] == 2
|
||||
|
||||
conflict = admin_client.patch(
|
||||
f"/api/ticket-drafts/{created['public_id']}",
|
||||
data={"expected_revision": 1, "fields": ticket_fields()},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
|
||||
def test_confirm_is_idempotent_and_records_accepted(admin_client, category):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
fields = ticket_fields()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="ready",
|
||||
current_fields=fields,
|
||||
last_ai_fields=fields,
|
||||
)
|
||||
|
||||
first = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": draft.revision},
|
||||
content_type="application/json",
|
||||
)
|
||||
second = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": draft.revision},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["confirmed_ticket"]["id"] == second.json()["confirmed_ticket"]["id"]
|
||||
assert first.json()["decision"] == "accepted"
|
||||
assert Ticket.objects.count() == 1
|
||||
assert Ticket.objects.get().ai_solution == ""
|
||||
|
||||
|
||||
def test_confirm_records_modified_and_reject_records_rejected(admin_client, category):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="ready",
|
||||
current_fields=ticket_fields(title="用户修改后的标题"),
|
||||
last_ai_fields=ticket_fields(),
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": 1},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.json()["decision"] == "modified"
|
||||
|
||||
rejected = TicketDraft.objects.create(created_by=creator, source_text="放弃")
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{rejected.public_id}/reject",
|
||||
data={"expected_revision": 1},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.json()["decision"] == "rejected"
|
||||
|
||||
|
||||
def test_guided_answers_stop_after_five_rounds(admin_client, db):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=get_user_model().objects.filter(is_superuser=True).first(),
|
||||
guided_rounds=4,
|
||||
)
|
||||
add_answer(draft, "第五轮回答", "第五轮问题")
|
||||
draft.refresh_from_db()
|
||||
assert draft.guided_rounds == 5
|
||||
assert TicketDraftMessage.objects.filter(draft=draft).count() == 2
|
||||
with pytest.raises(TicketDraftError, match="最多进行 5 轮"):
|
||||
add_answer(draft, "第六轮", "")
|
||||
|
||||
|
||||
def test_image_upload_validates_content_and_never_audits_source_text(admin_client, db, monkeypatch):
|
||||
create_ticket_agent(capability="vision_chat", approved=True)
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "包含敏感客户信息的原文"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *args: None)
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color="red").save(output, format="PNG")
|
||||
upload = SimpleUploadedFile("proof.png", output.getvalue(), content_type="text/plain")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/images", {"file": upload}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["images"][0]["mime_type"] == "image/png"
|
||||
snapshots = list(
|
||||
OperationAuditLog.objects.filter(target_id=created["public_id"]).values_list(
|
||||
"request_snapshot", flat=True
|
||||
)
|
||||
)
|
||||
assert "包含敏感客户信息的原文" not in str(snapshots)
|
||||
|
||||
|
||||
def test_text_model_rejects_image_analysis(admin_client, db):
|
||||
create_ticket_agent(capability="text_chat")
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "任务未更新"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
draft = TicketDraft.objects.get(public_id=created["public_id"])
|
||||
draft.images.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/analyze",
|
||||
data={"expected_revision": created["revision"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "不支持图片" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_cleanup_deletes_unconfirmed_details_but_keeps_confirmed_draft(
|
||||
admin_client, db, monkeypatch
|
||||
):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
expired = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
source_text="过期敏感原文",
|
||||
expires_at=timezone.now(),
|
||||
)
|
||||
expired.images.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
)
|
||||
confirmed = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="confirmed",
|
||||
expires_at=timezone.now(),
|
||||
confirmed_ticket=Ticket.objects.create(
|
||||
ticket_no="TK202607160001", title="已确认", description="保留"
|
||||
),
|
||||
)
|
||||
deleted_paths = []
|
||||
monkeypatch.setattr("apps.tickets.tasks.delete_object", lambda path: deleted_paths.append(path))
|
||||
|
||||
assert cleanup_expired_ticket_drafts() == 1
|
||||
assert not TicketDraft.objects.filter(id=expired.id).exists()
|
||||
assert TicketDraft.objects.filter(id=confirmed.id).exists()
|
||||
assert deleted_paths == ["ticket-drafts/proof.png"]
|
||||
audit = OperationAuditLog.objects.get(action="draft_expired", target_id=str(expired.public_id))
|
||||
assert "过期敏感原文" not in str(audit.request_snapshot)
|
||||
Reference in New Issue
Block a user