Files
call-center/tests/test_whatsapp_adapter_service.py

442 lines
16 KiB
Python

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