442 lines
16 KiB
Python
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
|