from django.core.cache import cache from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.test import TestCase from rest_framework.test import APIClient from basic_info import models as basic_models from mission import models as mission_models from printing import models as printing_models def grant_permission(user, codename): permission = Permission.objects.get(codename=codename) user.user_permissions.add(permission) class MissionV2APITest(TestCase): def setUp(self): cache.clear() self.client = APIClient() self.merchant = basic_models.Merchant.objects.create( name="任务商户", type=basic_models.MerchantTypeEnum.STORE, ) self.other_merchant = basic_models.Merchant.objects.create( name="其他商户", type=basic_models.MerchantTypeEnum.STORE, ) self.user = get_user_model().objects.create_user(username="mission-user", password="pass12345") self.employee = basic_models.Employee.objects.create( merchant=self.merchant, sys_user=self.user, name="任务员工", ) self.default_category = mission_models.MissionCategory.objects.create( merchant=self.merchant, name="通用", ) self.followup_category = mission_models.MissionCategory.objects.create( merchant=self.merchant, name="跟进", ) self.other_category = mission_models.MissionCategory.objects.create( merchant=self.other_merchant, name="通用", ) self.participant = basic_models.Employee.objects.create( merchant=self.merchant, name="参与者", ) self.other_employee = basic_models.Employee.objects.create( merchant=self.other_merchant, name="其他员工", ) self.customer = basic_models.Customer.objects.create( merchant=self.merchant, name="任务客户", mobile="13800138000", ) self.other_customer = basic_models.Customer.objects.create( merchant=self.other_merchant, name="其他客户", mobile="13800138001", ) self.printing_order = printing_models.PrintingOrder.objects.create( merchant=self.merchant, customer=self.customer, fabric="测试面料", width="150cm", created_by=self.user, ) self.other_printing_order = printing_models.PrintingOrder.objects.create( merchant=self.other_merchant, customer=self.other_customer, fabric="其他面料", width="160cm", created_by=self.user, ) self.printing_order_content_type = ContentType.objects.get_for_model(printing_models.PrintingOrder) self.client.force_authenticate(user=self.user) def test_create_mission_uses_default_status_and_current_employee(self): resp = self.client.post( "/api/v2/missions/", { "description": "跟进客户问题", "extra": {"source": "api-test", "priority": 1}, "participant_ids": [self.participant.id], }, format="json", ) self.assertEqual(resp.status_code, 201) mission = mission_models.Mission.objects.get(id=resp.data["id"]) self.assertEqual(mission.merchant, self.merchant) self.assertEqual(mission.creator, self.employee) self.assertEqual(mission.category, self.default_category) self.assertFalse(mission.is_urgent) self.assertFalse(mission.is_completed) self.assertFalse(mission.is_cancelled) self.assertFalse(mission.notify_if_unreplied) self.assertEqual(mission.unreplied_notify_max_count, 5) self.assertEqual(mission.extra, {"source": "api-test", "priority": 1}) self.assertEqual(resp.data["category"], self.default_category.id) self.assertEqual(resp.data["category_name"], "通用") self.assertEqual(resp.data["extra"], {"source": "api-test", "priority": 1}) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) def test_create_mission_supports_unreplied_notification_fields(self): resp = self.client.post( "/api/v2/missions/", { "description": "需要未回复提醒", "notify_if_unreplied": True, "unreplied_notify_interval_minutes": 30, }, format="json", ) self.assertEqual(resp.status_code, 201) self.assertTrue(resp.data["notify_if_unreplied"]) self.assertEqual(resp.data["unreplied_notify_interval_minutes"], 30) self.assertEqual(resp.data["unreplied_notify_max_count"], 5) self.assertEqual(resp.data["unreplied_notify_sent_count"], 0) def test_create_mission_rejects_missing_interval_when_unreplied_notification_enabled(self): resp = self.client.post( "/api/v2/missions/", { "description": "缺少提醒间隔", "notify_if_unreplied": True, }, format="json", ) self.assertEqual(resp.status_code, 400) self.assertIn("unreplied_notify_interval_minutes", resp.data) def test_create_mission_rejects_status_fields(self): resp = self.client.post( "/api/v2/missions/", { "description": "非法状态", "is_completed": True, }, format="json", ) self.assertEqual(resp.status_code, 400) self.assertIn("is_completed", resp.data) def test_create_mission_rejects_cross_merchant_category(self): resp = self.client.post( "/api/v2/missions/", { "description": "非法分类", "category": self.other_category.id, }, format="json", ) self.assertEqual(resp.status_code, 400) self.assertIn("category", resp.data) def test_patch_mission_rejects_status_fields(self): mission = self._create_mission() resp = self.client.patch( f"/api/v2/missions/{mission.id}/", {"is_cancelled": True}, format="json", ) self.assertEqual(resp.status_code, 400) mission.refresh_from_db() self.assertFalse(mission.is_cancelled) def test_patch_mission_rejects_is_urgent(self): mission = self._create_mission() resp = self.client.patch( f"/api/v2/missions/{mission.id}/", {"is_urgent": True}, format="json", ) self.assertEqual(resp.status_code, 400) mission.refresh_from_db() self.assertFalse(mission.is_urgent) def test_patch_mission_updates_normal_fields_and_participants(self): mission = self._create_mission() resp = self.client.patch( f"/api/v2/missions/{mission.id}/", { "description": "更新后的任务", "category": self.followup_category.id, "extra": {"channel": "wechat"}, "participant_ids": [self.participant.id], }, format="json", ) self.assertEqual(resp.status_code, 200) mission.refresh_from_db() self.assertEqual(mission.description, "更新后的任务") self.assertEqual(mission.category, self.followup_category) self.assertEqual(mission.extra, {"channel": "wechat"}) self.assertEqual(resp.data["category"], self.followup_category.id) self.assertEqual(resp.data["category_name"], "跟进") self.assertEqual(resp.data["extra"], {"channel": "wechat"}) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) def test_create_mission_reply_supports_extra(self): mission = self._create_mission() resp = self.client.post( f"/api/v2/missions/{mission.id}/replies/", {"content": "已记录", "extra": {"attachment_ids": [1, 2]}}, format="json", ) self.assertEqual(resp.status_code, 201) reply = mission_models.MissionReply.objects.get(id=resp.data["id"]) self.assertEqual(reply.extra, {"attachment_ids": [1, 2]}) self.assertEqual(resp.data["extra"], {"attachment_ids": [1, 2]}) def test_patch_mission_updates_unreplied_notification_fields(self): mission = self._create_mission() resp = self.client.patch( f"/api/v2/missions/{mission.id}/", { "notify_if_unreplied": True, "unreplied_notify_interval_minutes": 12, "unreplied_notify_max_count": 7, }, format="json", ) self.assertEqual(resp.status_code, 200) mission.refresh_from_db() self.assertTrue(mission.notify_if_unreplied) self.assertEqual(mission.unreplied_notify_interval_minutes, 12) self.assertEqual(mission.unreplied_notify_max_count, 7) def test_list_only_returns_current_merchant_missions(self): visible = self._create_mission(description="可见任务") mission_models.Mission.objects.create( merchant=self.other_merchant, category=self.other_category, creator=self.other_employee, description="不可见任务", ) resp = self.client.get("/api/v2/missions/") self.assertEqual(resp.status_code, 200) self.assertEqual([item["id"] for item in resp.data], [visible.id]) def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self): mission = mission_models.Mission.objects.create( merchant=self.merchant, category=self.default_category, creator=self.employee, description="印花订单任务", content_type=self.printing_order_content_type, content_id=self.printing_order.id, extra={"source": "printing-order"}, ) mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="已跟进", extra={"attachment_ids": [101]}, ) mission_models.Mission.objects.create( merchant=self.merchant, category=self.followup_category, creator=self.employee, description="其他订单任务", content_type=self.printing_order_content_type, content_id=self.other_printing_order.id, ) resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") self.assertEqual(resp.status_code, 200) self.assertEqual(len(resp.data), 1) self.assertEqual(resp.data[0]["id"], mission.id) self.assertEqual(resp.data[0]["extra"], {"source": "printing-order"}) self.assertEqual(len(resp.data[0]["replies"]), 1) self.assertEqual(resp.data[0]["replies"][0]["content"], "已跟进") self.assertEqual(resp.data[0]["replies"][0]["extra"], {"attachment_ids": [101]}) def test_list_missions_by_printing_order_supports_category_filter(self): mission_models.Mission.objects.create( merchant=self.merchant, category=self.default_category, creator=self.employee, description="通用任务", content_type=self.printing_order_content_type, content_id=self.printing_order.id, ) followup_mission = mission_models.Mission.objects.create( merchant=self.merchant, category=self.followup_category, creator=self.employee, description="跟进任务", content_type=self.printing_order_content_type, content_id=self.printing_order.id, ) resp = self.client.get( f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?category_ids={self.followup_category.id}" ) self.assertEqual(resp.status_code, 200) self.assertEqual([item["id"] for item in resp.data], [followup_mission.id]) def test_list_missions_by_printing_order_supports_lightweight_mode(self): mission = mission_models.Mission.objects.create( merchant=self.merchant, category=self.default_category, creator=self.employee, description="轻量任务", content_type=self.printing_order_content_type, content_id=self.printing_order.id, ) mission_models.MissionParticipant.objects.create( merchant=self.merchant, mission=mission, employee=self.participant, ) mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="已处理", ends_task=True, ) resp = self.client.get( f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" ) self.assertEqual(resp.status_code, 200) self.assertEqual(len(resp.data), 1) self.assertEqual(resp.data[0]["id"], mission.id) self.assertNotIn("participants", resp.data[0]) self.assertNotIn("has_ending_reply", resp.data[0]) self.assertNotIn("can_reply", resp.data[0]) self.assertNotIn("replies", resp.data[0]) def test_list_missions_by_printing_order_rejects_invalid_include_details(self): resp = self.client.get( f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=maybe" ) self.assertEqual(resp.status_code, 400) self.assertIn("include_details", resp.data) def test_list_missions_by_printing_order_uses_separate_cache_by_detail_mode(self): mission = mission_models.Mission.objects.create( merchant=self.merchant, category=self.default_category, creator=self.employee, description="缓存任务", content_type=self.printing_order_content_type, content_id=self.printing_order.id, ) mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="首次回复", ) detailed_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") lightweight_resp = self.client.get( f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" ) mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="后续回复", ) detailed_cached_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") lightweight_cached_resp = self.client.get( f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" ) self.assertEqual(len(detailed_resp.data[0]["replies"]), 1) self.assertEqual(len(detailed_cached_resp.data[0]["replies"]), 1) self.assertNotIn("replies", lightweight_resp.data[0]) self.assertNotIn("replies", lightweight_cached_resp.data[0]) def test_list_missions_by_printing_order_rejects_cross_merchant_order(self): resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.other_printing_order.id}/") self.assertEqual(resp.status_code, 404) def test_delete_mission_is_not_allowed(self): mission = self._create_mission() resp = self.client.delete(f"/api/v2/missions/{mission.id}/") self.assertEqual(resp.status_code, 405) def test_reopen_mission_requires_permission(self): mission = self._create_mission() reply_resp = self.client.post( f"/api/v2/missions/{mission.id}/replies/", {"content": "已处理", "ends_task": True}, format="json", ) self.assertEqual(reply_resp.status_code, 201) mission.refresh_from_db() self.assertTrue(mission.is_completed) reopen_resp = self.client.post(f"/api/v2/missions/{mission.id}/reopen/", {}, format="json") self.assertEqual(reopen_resp.status_code, 403) def test_create_ending_reply_and_reopen_mission(self): grant_permission(self.user, "reopen_mission") mission = self._create_mission() reply_resp = self.client.post( f"/api/v2/missions/{mission.id}/replies/", {"content": "已处理", "ends_task": True}, format="json", ) self.assertEqual(reply_resp.status_code, 201) mission.refresh_from_db() self.assertTrue(mission.is_completed) reopen_resp = self.client.post(f"/api/v2/missions/{mission.id}/reopen/", {}, format="json") self.assertEqual(reopen_resp.status_code, 200) mission.refresh_from_db() reply = mission_models.MissionReply.objects.get(id=reply_resp.data["id"]) self.assertFalse(mission.is_completed) self.assertFalse(reply.ends_task) self.assertTrue(reply.is_rejected) self.assertEqual(reply.rejected_by, self.employee) def test_reject_reply_requires_permission(self): mission = self._create_mission() reply = mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="结束", ends_task=True, ) mission.is_completed = True mission.save(update_fields=["is_completed", "updated_at"]) resp = self.client.post(f"/api/v2/mission-replies/{reply.id}/reject/", {}, format="json") self.assertEqual(resp.status_code, 403) def test_reject_reply_api_reopens_mission_when_rejecting_ending_reply(self): grant_permission(self.user, "reject_mission_reply") mission = self._create_mission() reply = mission_models.MissionReply.objects.create( merchant=self.merchant, mission=mission, responder=self.employee, content="结束", ends_task=True, ) mission.is_completed = True mission.save(update_fields=["is_completed", "updated_at"]) resp = self.client.post(f"/api/v2/mission-replies/{reply.id}/reject/", {}, format="json") self.assertEqual(resp.status_code, 200) mission.refresh_from_db() reply.refresh_from_db() self.assertFalse(mission.is_completed) self.assertTrue(reply.is_rejected) self.assertEqual(reply.rejected_by, self.employee) def test_cancel_mission_api_records_cancelled_by(self): mission = self._create_mission() resp = self.client.post(f"/api/v2/missions/{mission.id}/cancel/", {}, format="json") self.assertEqual(resp.status_code, 200) mission.refresh_from_db() self.assertTrue(mission.is_cancelled) self.assertEqual(mission.cancelled_by, self.employee) self.assertIsNotNone(mission.cancelled_at) def test_set_urgent_api_updates_urgent_status(self): mission = self._create_mission() resp = self.client.post( f"/api/v2/missions/{mission.id}/set-urgent/", {"is_urgent": True}, format="json", ) self.assertEqual(resp.status_code, 200) mission.refresh_from_db() self.assertTrue(mission.is_urgent) def test_cross_merchant_mission_detail_returns_404(self): mission = mission_models.Mission.objects.create( merchant=self.other_merchant, category=self.other_category, creator=self.other_employee, description="其他商户任务", ) resp = self.client.get(f"/api/v2/missions/{mission.id}/") self.assertEqual(resp.status_code, 404) def test_cross_merchant_participant_is_rejected(self): resp = self.client.post( "/api/v2/missions/", { "description": "跨商户参与者", "participant_ids": [self.other_employee.id], }, format="json", ) self.assertEqual(resp.status_code, 400) def test_mission_category_crud(self): list_resp = self.client.get("/api/v2/mission-categories/") self.assertEqual(list_resp.status_code, 200) self.assertEqual( [item["name"] for item in list_resp.data], ["通用", "跟进"], ) self.assertEqual(list_resp.data[0]["payload_processor"], "") self.assertFalse(list_resp.data[0]["speech_enabled"]) create_resp = self.client.post( "/api/v2/mission-categories/", {"name": "售后", "payload_processor": "structured_description_v1", "speech_enabled": True}, format="json", ) self.assertEqual(create_resp.status_code, 201) category_id = create_resp.data["id"] self.assertEqual(create_resp.data["payload_processor"], "structured_description_v1") self.assertTrue(create_resp.data["speech_enabled"]) detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/") self.assertEqual(detail_resp.status_code, 200) self.assertEqual(detail_resp.data["name"], "售后") self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1") self.assertTrue(detail_resp.data["speech_enabled"]) patch_resp = self.client.patch( f"/api/v2/mission-categories/{category_id}/", {"name": "售后跟进", "payload_processor": "", "speech_enabled": False}, format="json", ) self.assertEqual(patch_resp.status_code, 200) self.assertEqual(patch_resp.data["name"], "售后跟进") self.assertEqual(patch_resp.data["payload_processor"], "") self.assertFalse(patch_resp.data["speech_enabled"]) delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/") self.assertEqual(delete_resp.status_code, 204) def test_mission_category_rejects_unknown_payload_processor(self): resp = self.client.post( "/api/v2/mission-categories/", {"name": "售后", "payload_processor": "unknown_processor"}, format="json", ) self.assertEqual(resp.status_code, 400) self.assertIn("payload_processor", resp.data) def test_delete_used_mission_category_is_rejected(self): mission = self._create_mission() resp = self.client.delete(f"/api/v2/mission-categories/{mission.category_id}/") self.assertEqual(resp.status_code, 400) def _create_mission(self, description="测试任务"): return mission_models.Mission.objects.create( merchant=self.merchant, category=self.default_category, creator=self.employee, description=description, )