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)