import hashlib import hmac import json from fastapi.testclient import TestClient from sqlalchemy import select from services.interaction_service.app import app as interaction_app from services.shared.db import get_session from services.shared.sql_models import ( CustomerExternalIdentity, Interaction, WhatsAppMessageRow, WhatsAppThreadRow, ) from services.whatsapp_adapter_service import app as whatsapp_module from services.whatsapp_adapter_service.app import app as whatsapp_app def admin_headers(): return {"X-User": "admin", "X-Role": "admin"} def operator_headers(user="operator"): return {"X-User": user, "X-Role": "operator"} def patch_interaction_request(monkeypatch): def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: session = get_session() try: interaction_id = path.split("/")[2] interaction = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one() if path.endswith("/assign"): interaction.assigned_to = payload["assignee"] interaction.status = "in_progress" elif path.endswith("/status"): interaction.status = payload["status"] elif path.endswith("/escalate"): interaction.status = "escalated" interaction.queue_id = payload["target_queue_id"] interaction.updated_at = whatsapp_module.utc_now_iso() session.commit() return { "interaction_id": interaction.interaction_id, "status": interaction.status, "assigned_to": interaction.assigned_to, "queue_id": interaction.queue_id, } finally: session.close() monkeypatch.setattr(whatsapp_module, "_interaction_request", fake_request) def _meta_signature(secret: str, body: bytes) -> str: digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return f"sha256={digest}" def test_manual_webhook_creates_thread_and_linked_interaction(): whatsapp_client = TestClient(whatsapp_app) interaction_client = TestClient(interaction_app) response = whatsapp_client.post( "/integrations/whatsapp/webhook", json={ "chat_id": "wa_chat_1", "text": "Привет из WhatsApp", "external_message_id": "wa_ext_1", "whatsapp_user_id": "wa_user_1", "phone_number": "+77015550001", "display_name": "Lead One", "payload": {"profile_name": "Lead One"}, }, ) assert response.status_code == 200 payload = response.json() assert payload["thread_id"].startswith("wht_") assert payload["interaction_id"].startswith("int_") assert payload["direction"] == "inbound" assert payload["customer_id"].startswith("cus_") thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers()) assert thread_list.status_code == 200 listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"]) assert listed_thread["chat_id"] == "wa_chat_1" assert listed_thread["phone_number"] == "+77015550001" assert listed_thread["unread_count"] == 1 interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") assert interaction.status_code == 200 assert interaction.json()["channel"] == "whatsapp" assert interaction.json()["customer_id"] == payload["customer_id"] session = get_session() try: identity = session.execute( select(CustomerExternalIdentity).where( CustomerExternalIdentity.channel == "whatsapp", CustomerExternalIdentity.external_subject == "wa_user_1", ) ).scalar_one_or_none() assert identity is not None assert identity.customer_id == payload["customer_id"] finally: session.close() def test_manual_webhook_deduplicates_by_external_message_id(): whatsapp_client = TestClient(whatsapp_app) payload = { "chat_id": "wa_chat_dedupe", "text": "Повторный inbound", "external_message_id": "wa_ext_dedupe_1", "whatsapp_user_id": "wa_user_dedupe", "phone_number": "+77015550002", "display_name": "Dedupe Lead", "payload": {"provider": "manual"}, } first = whatsapp_client.post("/integrations/whatsapp/webhook", json=payload) second = whatsapp_client.post("/integrations/whatsapp/webhook", json=payload) assert first.status_code == 200 assert second.status_code == 200 assert first.json()["message_id"] == second.json()["message_id"] assert first.json()["thread_id"] == second.json()["thread_id"] messages = whatsapp_client.get( f"/integrations/whatsapp/threads/{first.json()['thread_id']}/messages", headers=admin_headers(), ) assert messages.status_code == 200 assert len(messages.json()) == 1 def test_provider_webhook_accepts_meta_style_payload(monkeypatch): monkeypatch.setenv("WHATSAPP_WEBHOOK_SECRET", "wa-secret") whatsapp_client = TestClient(whatsapp_app) response = whatsapp_client.post( "/integrations/whatsapp/provider/webhook", headers={"X-WhatsApp-Webhook-Secret": "wa-secret"}, json={ "entry": [ { "changes": [ { "value": { "contacts": [{"wa_id": "77015550003", "profile": {"name": "Meta Lead"}}], "messages": [ { "id": "wam_meta_1", "from": "77015550003", "type": "text", "text": {"body": "Есть вопрос по доставке"}, } ], } } ] } ] }, ) assert response.status_code == 200 payload = response.json() assert payload["ok"] is True assert payload["thread_id"].startswith("wht_") assert payload["message_id"].startswith("wam_") messages = whatsapp_client.get( f"/integrations/whatsapp/threads/{payload['thread_id']}/messages", headers=admin_headers(), ) assert messages.status_code == 200 message = messages.json()[0] assert message["text"] == "Есть вопрос по доставке" assert message["author_type"] == "customer" assert message["whatsapp_message_id_external"] == "wam_meta_1" def test_provider_webhook_verification_returns_challenge(monkeypatch): monkeypatch.setenv("WHATSAPP_META_VERIFY_TOKEN", "wa-verify-token") whatsapp_client = TestClient(whatsapp_app) response = whatsapp_client.get( "/integrations/whatsapp/provider/webhook", params={ "hub.mode": "subscribe", "hub.verify_token": "wa-verify-token", "hub.challenge": "challenge-123", }, ) assert response.status_code == 200 assert response.text == "challenge-123" def test_provider_webhook_accepts_meta_signature(monkeypatch): monkeypatch.setenv("WHATSAPP_APP_SECRET", "meta-app-secret") whatsapp_client = TestClient(whatsapp_app) payload = { "entry": [ { "changes": [ { "value": { "contacts": [{"wa_id": "77015550013", "profile": {"name": "Signed Lead"}}], "messages": [ { "id": "wam_meta_signed_1", "from": "77015550013", "type": "text", "text": {"body": "Signed inbound"}, } ], } } ] } ] } body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") response = whatsapp_client.post( "/integrations/whatsapp/provider/webhook", headers={ "Content-Type": "application/json", "X-Hub-Signature-256": _meta_signature("meta-app-secret", body), }, content=body, ) assert response.status_code == 200 assert response.json()["ok"] is True assert response.json()["message_id"].startswith("wam_") def test_provider_webhook_status_callback_updates_delivery(monkeypatch): patch_interaction_request(monkeypatch) monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: None) whatsapp_client = TestClient(whatsapp_app) created = whatsapp_client.post( "/integrations/whatsapp/webhook", json={ "chat_id": "wa_chat_status", "text": "Need status update", "external_message_id": "wa_status_in_1", "whatsapp_user_id": "wa_user_status", "phone_number": "+77015550014", "display_name": "Status Lead", }, ) assert created.status_code == 200 thread_id = created.json()["thread_id"] claimed = whatsapp_client.post( f"/integrations/whatsapp/threads/{thread_id}/claim", headers=operator_headers("operator_status"), ) assert claimed.status_code == 200 reply = whatsapp_client.post( f"/integrations/whatsapp/threads/{thread_id}/messages", headers=operator_headers("operator_status"), json={"text": "Outbound for status"}, ) assert reply.status_code == 200 reply_message_id = reply.json()["message_id"] session = get_session() try: row = session.execute( select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == reply_message_id) ).scalar_one() row.whatsapp_message_id_external = "wam_status_meta_1" row.delivery_status = "sent" session.commit() finally: session.close() response = whatsapp_client.post( "/integrations/whatsapp/provider/webhook", json={ "entry": [ { "changes": [ { "value": { "statuses": [ { "id": "wam_status_meta_1", "status": "delivered", "recipient_id": "77015550014", } ] } } ] } ] }, ) assert response.status_code == 200 assert response.json()["status_event"] is True assert response.json()["updated"] is True assert response.json()["delivery_status"] == "delivered" session = get_session() try: row = session.execute( select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == reply_message_id) ).scalar_one() assert row.delivery_status == "delivered" finally: session.close() def test_send_whatsapp_message_uses_meta_graph_api(monkeypatch): captured: dict[str, object] = {} class DummyMetaResponse: status_code = 200 text = '{"messages":[{"id":"wam_meta_sent_42"}]}' @staticmethod def json() -> dict: return {"messages": [{"id": "wam_meta_sent_42"}]} class DummyMetaClient: def __init__(self, timeout: float): captured["timeout"] = timeout def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None def post(self, url, json=None, headers=None): # noqa: ANN001, ANN201 captured["url"] = url captured["json"] = json captured["headers"] = headers return DummyMetaResponse() monkeypatch.setenv("WHATSAPP_META_ACCESS_TOKEN", "meta-token") monkeypatch.setenv("WHATSAPP_META_PHONE_NUMBER_ID", "973115089226218") monkeypatch.setattr(whatsapp_module.httpx, "Client", DummyMetaClient) payload = whatsapp_module._send_whatsapp_message("77015550015", "Meta outbound hello") assert payload["ok"] is True assert payload["result"]["message_id"] == "wam_meta_sent_42" assert captured["url"] == "https://graph.facebook.com/v22.0/973115089226218/messages" assert captured["headers"]["Authorization"] == "Bearer meta-token" assert captured["json"] == { "messaging_product": "whatsapp", "to": "77015550015", "type": "text", "text": {"body": "Meta outbound hello"}, } def test_reply_flow_claims_and_delivers_pending_message(monkeypatch): patch_interaction_request(monkeypatch) started: list[str] = [] monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: started.append(message_id)) whatsapp_client = TestClient(whatsapp_app) created = whatsapp_client.post( "/integrations/whatsapp/webhook", json={ "chat_id": "wa_chat_reply", "text": "Нужен ответ", "external_message_id": "wa_reply_in_1", "whatsapp_user_id": "wa_user_reply", "phone_number": "+77015550004", "display_name": "Reply Lead", }, ) assert created.status_code == 200 thread_id = created.json()["thread_id"] claimed = whatsapp_client.post( f"/integrations/whatsapp/threads/{thread_id}/claim", headers=operator_headers("operator_wa"), ) assert claimed.status_code == 200 assert claimed.json()["unread_count"] == 1 reply = whatsapp_client.post( f"/integrations/whatsapp/threads/{thread_id}/messages", headers=operator_headers("operator_wa"), json={"text": "Отправляем подтверждение"}, ) assert reply.status_code == 200 payload = reply.json() assert payload["delivery_status"] == "pending" assert started == [payload["message_id"]] monkeypatch.setattr( whatsapp_module, "_send_whatsapp_message", lambda chat_id, text: {"ok": True, "result": {"message_id": "wam_sent_1", "chat_id": chat_id, "text": text}}, ) assert whatsapp_module._deliver_pending_whatsapp_reply(payload["message_id"]) is True messages = whatsapp_client.get( f"/integrations/whatsapp/threads/{thread_id}/messages", headers=admin_headers(), ) assert messages.status_code == 200 delivered = messages.json()[-1] assert delivered["delivery_status"] == "sent" assert delivered["whatsapp_message_id_external"] == "wam_sent_1" session = get_session() try: row = session.execute( select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == payload["message_id"]) ).scalar_one() thread = session.execute( select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id) ).scalar_one() assert row.delivery_status == "sent" assert row.whatsapp_message_id_external == "wam_sent_1" assert thread.ai_state == "human_owned" finally: session.close() thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers()) assert thread_list.status_code == 200 listed_thread = next(item for item in thread_list.json() if item["thread_id"] == thread_id) assert listed_thread["unread_count"] == 0