Initial import with GitLab CI/CD and registry deploy flow
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
import threading
|
||||
|
||||
from fastapi import HTTPException
|
||||
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 Customer, CustomerExternalIdentity, Interaction, TelegramMessageRow, TelegramThreadRow
|
||||
from services.telegram_adapter_service import app as telegram_module
|
||||
from services.telegram_adapter_service.app import app as telegram_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 = telegram_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(telegram_module, "_interaction_request", fake_request)
|
||||
|
||||
|
||||
def test_manual_webhook_creates_thread_and_linked_interaction():
|
||||
telegram_client = TestClient(telegram_app)
|
||||
interaction_client = TestClient(interaction_app)
|
||||
|
||||
response = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={
|
||||
"chat_id": "chat_1",
|
||||
"text": "Привет из Telegram",
|
||||
"customer_external_id": "telegram:user_1",
|
||||
"payload": {
|
||||
"username": "lead_one",
|
||||
"first_name": "Lead",
|
||||
"last_name": "One",
|
||||
"telegram_user_id": "tg_user_1",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["thread_id"].startswith("tgt_")
|
||||
assert payload["interaction_id"].startswith("int_")
|
||||
assert payload["direction"] == "inbound"
|
||||
|
||||
thread_list = telegram_client.get("/integrations/telegram/threads", headers=admin_headers())
|
||||
assert thread_list.status_code == 200
|
||||
assert thread_list.json()[0]["chat_id"] == "chat_1"
|
||||
assert thread_list.json()[0]["interaction_id"] == payload["interaction_id"]
|
||||
|
||||
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
|
||||
assert interaction.status_code == 200
|
||||
assert interaction.json()["channel"] == "telegram"
|
||||
assert interaction.json()["customer_id"].startswith("cus_")
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
identity = session.execute(
|
||||
select(CustomerExternalIdentity).where(
|
||||
CustomerExternalIdentity.channel == "telegram",
|
||||
CustomerExternalIdentity.external_subject == "tg_user_1",
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
assert identity is not None
|
||||
assert identity.customer_id == interaction.json()["customer_id"]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_repeated_inbound_reuses_same_thread_and_interaction():
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
first = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_reuse", "text": "Первое", "payload": {"username": "reuse_user"}},
|
||||
)
|
||||
second = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_reuse", "text": "Второе", "payload": {"username": "reuse_user"}},
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["thread_id"] == second.json()["thread_id"]
|
||||
assert first.json()["interaction_id"] == second.json()["interaction_id"]
|
||||
|
||||
|
||||
def test_list_messages_requires_roles():
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_messages_auth", "text": "Auth check"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
anonymous = telegram_client.get(
|
||||
"/integrations/telegram/messages",
|
||||
params={"chat_id": "chat_messages_auth"},
|
||||
)
|
||||
assert anonymous.status_code == 403
|
||||
|
||||
admin = telegram_client.get(
|
||||
"/integrations/telegram/messages",
|
||||
params={"chat_id": "chat_messages_auth"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
assert len(admin.json()) == 1
|
||||
|
||||
operator = telegram_client.get(
|
||||
"/integrations/telegram/messages",
|
||||
params={"chat_id": "chat_messages_auth"},
|
||||
headers=operator_headers(),
|
||||
)
|
||||
assert operator.status_code == 200
|
||||
assert len(operator.json()) == 1
|
||||
|
||||
|
||||
def test_closed_interaction_is_reactivated_on_new_inbound():
|
||||
telegram_client = TestClient(telegram_app)
|
||||
interaction_client = TestClient(interaction_app)
|
||||
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_reactivate", "text": "Первое сообщение"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
interaction_id = created.json()["interaction_id"]
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
interaction = session.execute(select(Interaction).where(Interaction.interaction_id == interaction_id)).scalar_one()
|
||||
thread.status = "closed"
|
||||
interaction.status = "closed"
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
reopened = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_reactivate", "text": "Новое сообщение после close"},
|
||||
)
|
||||
assert reopened.status_code == 200
|
||||
assert reopened.json()["thread_id"] == thread_id
|
||||
assert reopened.json()["interaction_id"] == interaction_id
|
||||
|
||||
interaction = interaction_client.get(f"/interactions/{interaction_id}")
|
||||
assert interaction.status_code == 200
|
||||
assert interaction.json()["status"] == "new"
|
||||
|
||||
|
||||
def test_operator_can_claim_thread_and_foreign_operator_cannot_reply(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_claim", "text": "Нужна помощь"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_a"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
assert claimed.json()["claimed_by_user"] == "operator_a"
|
||||
assert claimed.json()["status"] == "in_progress"
|
||||
|
||||
forbidden = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=operator_headers("operator_b"),
|
||||
json={"text": "Чужой reply"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_operator_reply_sends_outbound_message_and_persists_row(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
started: list[str] = []
|
||||
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: started.append(message_id))
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_reply", "text": "Вопрос клиента"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
|
||||
sent = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=operator_headers("operator"),
|
||||
json={"text": "Ответ оператора"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
payload = sent.json()
|
||||
assert payload["direction"] == "outbound"
|
||||
assert payload["delivery_status"] == "pending"
|
||||
assert payload["telegram_message_id_external"] is None
|
||||
assert payload["operator_user"] == "operator"
|
||||
assert payload["author_type"] == "human"
|
||||
assert payload["author_id"] == "operator"
|
||||
assert started == [payload["message_id"]]
|
||||
|
||||
messages = telegram_client.get(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert messages.status_code == 200
|
||||
assert len(messages.json()) == 2
|
||||
assert messages.json()[-1]["delivery_status"] == "pending"
|
||||
assert messages.json()[-1]["text"] == "Ответ оператора"
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_send_telegram_message",
|
||||
lambda chat_id, text: {"ok": True, "result": {"message_id": 7788, "chat": {"id": chat_id}, "text": text}},
|
||||
)
|
||||
telegram_module._deliver_pending_telegram_reply(payload["message_id"])
|
||||
|
||||
delivered = telegram_client.get(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert delivered.status_code == 200
|
||||
assert delivered.json()[-1]["delivery_status"] == "sent"
|
||||
assert delivered.json()[-1]["telegram_message_id_external"] == "7788"
|
||||
|
||||
|
||||
def test_returning_human_owned_thread_to_ai_reopens_ai_flow(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
enqueue_calls: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_ai_enqueue_request",
|
||||
lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)),
|
||||
)
|
||||
patch_interaction_request(monkeypatch)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={
|
||||
"chat_id": "chat_return_ai",
|
||||
"text": "Первое сообщение",
|
||||
"payload": {"telegram_user_id": "tg_return_ai", "username": "return_ai_user"},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
thread_id = created.json()["thread_id"]
|
||||
first_message_id = created.json()["message_id"]
|
||||
enqueue_calls.clear()
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_ai"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
assert claimed.json()["ai_state"] == "human_owned"
|
||||
|
||||
returned = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/return-to-ai",
|
||||
headers=operator_headers("operator_ai"),
|
||||
)
|
||||
assert returned.status_code == 200
|
||||
assert returned.json()["ai_state"] == "queued"
|
||||
assert returned.json()["claimed_by_user"] is None
|
||||
assert returned.json()["status"] == "new"
|
||||
assert enqueue_calls == []
|
||||
|
||||
follow_up = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={
|
||||
"chat_id": "chat_return_ai",
|
||||
"text": "Новое сообщение после возврата AI",
|
||||
"payload": {"telegram_user_id": "tg_return_ai", "username": "return_ai_user"},
|
||||
},
|
||||
)
|
||||
assert follow_up.status_code == 200
|
||||
assert follow_up.json()["thread_id"] == thread_id
|
||||
assert follow_up.json()["message_id"] != first_message_id
|
||||
assert enqueue_calls == [(thread_id, follow_up.json()["message_id"])]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(
|
||||
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)
|
||||
).scalar_one()
|
||||
interaction = session.execute(
|
||||
select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
|
||||
).scalar_one()
|
||||
assert thread.ai_state == "queued"
|
||||
assert thread.claimed_by_user is None
|
||||
assert thread.status == "new"
|
||||
assert interaction.status == "new"
|
||||
assert interaction.assigned_to is None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_failed_reply_is_scheduled_for_retry_and_then_sent(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
monkeypatch.setenv("TELEGRAM_REPLY_DELIVERY_POLL_SECONDS", "3600")
|
||||
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_retry", "text": "Retry me"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_retry"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
|
||||
reply = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=operator_headers("operator_retry"),
|
||||
json={"text": "Please retry"},
|
||||
)
|
||||
assert reply.status_code == 200
|
||||
message_id = reply.json()["message_id"]
|
||||
|
||||
attempts: list[int] = []
|
||||
|
||||
def flaky_send(chat_id: str, text: str) -> dict:
|
||||
attempts.append(len(attempts) + 1)
|
||||
if len(attempts) == 1:
|
||||
raise HTTPException(status_code=502, detail="temporary send failure")
|
||||
return {"ok": True, "result": {"message_id": 8899, "chat": {"id": chat_id}, "text": text}}
|
||||
|
||||
monkeypatch.setattr(telegram_module, "_send_telegram_message", flaky_send)
|
||||
|
||||
assert telegram_module._deliver_pending_telegram_reply(message_id) is False
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id)
|
||||
).scalar_one()
|
||||
assert row.delivery_status == "retrying"
|
||||
assert row.delivery_attempts == 1
|
||||
assert row.next_delivery_attempt_at is not None
|
||||
assert row.delivery_locked_until is None
|
||||
assert row.last_delivery_error == "temporary send failure"
|
||||
row.next_delivery_attempt_at = telegram_module.utc_now_iso()
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
assert telegram_module._deliver_pending_telegram_reply(message_id) is True
|
||||
assert attempts == [1, 2]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id)
|
||||
).scalar_one()
|
||||
assert row.delivery_status == "sent"
|
||||
assert row.delivery_attempts == 2
|
||||
assert row.telegram_message_id_external == "8899"
|
||||
assert row.next_delivery_attempt_at is None
|
||||
assert row.delivery_locked_until is None
|
||||
assert row.last_delivery_error is None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_worker_batch_recovers_stale_sending_reply(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
monkeypatch.setenv("TELEGRAM_REPLY_DELIVERY_POLL_SECONDS", "3600")
|
||||
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_send_telegram_message",
|
||||
lambda chat_id, text: {"ok": True, "result": {"message_id": 9901, "chat": {"id": chat_id}, "text": text}},
|
||||
)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_stale_worker", "text": "Recover me"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_stale"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
|
||||
reply = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=operator_headers("operator_stale"),
|
||||
json={"text": "Stale sending"},
|
||||
)
|
||||
assert reply.status_code == 200
|
||||
message_id = reply.json()["message_id"]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id)
|
||||
).scalar_one()
|
||||
row.delivery_status = "sending"
|
||||
row.delivery_locked_until = "2000-01-01T00:00:00+00:00"
|
||||
row.next_delivery_attempt_at = telegram_module.utc_now_iso()
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
processed = telegram_module._process_due_telegram_reply_batch(batch_size=1)
|
||||
assert processed == 1
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id)
|
||||
).scalar_one()
|
||||
assert row.delivery_status == "sent"
|
||||
assert row.delivery_attempts == 1
|
||||
assert row.telegram_message_id_external == "9901"
|
||||
assert row.delivery_locked_until is None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_bot_webhook_secret_and_unsupported_content_placeholder(monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "secret-15")
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
forbidden = telegram_client.post(
|
||||
"/integrations/telegram/bot/webhook",
|
||||
json={"message": {"message_id": 1, "chat": {"id": 44}}},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
accepted = telegram_client.post(
|
||||
"/integrations/telegram/bot/webhook",
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "secret-15"},
|
||||
json={
|
||||
"message": {
|
||||
"message_id": 9,
|
||||
"chat": {"id": 555},
|
||||
"from": {"id": 77, "username": "photo_user"},
|
||||
"photo": [{"file_id": "abc"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
thread_id = accepted.json()["thread_id"]
|
||||
|
||||
messages = telegram_client.get(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert messages.status_code == 200
|
||||
assert messages.json()[0]["direction"] == "system"
|
||||
assert "[Unsupported Telegram content: photo]" in messages.json()[0]["text"]
|
||||
|
||||
|
||||
def test_bot_webhook_unsupported_content_does_not_enqueue_ai(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
enqueue_calls: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_ai_enqueue_request",
|
||||
lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)),
|
||||
)
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
accepted = telegram_client.post(
|
||||
"/integrations/telegram/bot/webhook",
|
||||
json={
|
||||
"message": {
|
||||
"message_id": 19,
|
||||
"chat": {"id": 559},
|
||||
"from": {"id": 79, "username": "photo_user"},
|
||||
"photo": [{"file_id": "abc"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 200
|
||||
assert enqueue_calls == []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(
|
||||
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == accepted.json()["thread_id"])
|
||||
).scalar_one()
|
||||
assert thread.ai_state is None
|
||||
assert thread.ai_handoff_reason is None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_bot_webhook_contact_saves_customer_phone(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
enqueue_calls: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_ai_enqueue_request",
|
||||
lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)),
|
||||
)
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
accepted = telegram_client.post(
|
||||
"/integrations/telegram/bot/webhook",
|
||||
json={
|
||||
"message": {
|
||||
"message_id": 21,
|
||||
"chat": {"id": 562},
|
||||
"from": {"id": 81, "username": "contact_user", "first_name": "Contact"},
|
||||
"contact": {"phone_number": "+7 (777) 123-45-67", "user_id": 81},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 200
|
||||
assert enqueue_calls == []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(
|
||||
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == accepted.json()["thread_id"])
|
||||
).scalar_one()
|
||||
interaction = session.execute(
|
||||
select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
|
||||
).scalar_one()
|
||||
customer = session.execute(
|
||||
select(Customer).where(Customer.customer_id == interaction.customer_id)
|
||||
).scalar_one_or_none()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
assert thread.ai_state is None
|
||||
assert customer is not None
|
||||
assert customer.preferred_phone == "+77771234567"
|
||||
assert "+77771234567" in customer.phones_json
|
||||
|
||||
messages = telegram_client.get(
|
||||
f"/integrations/telegram/threads/{accepted.json()['thread_id']}/messages",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert messages.status_code == 200
|
||||
assert messages.json()[0]["direction"] == "system"
|
||||
assert messages.json()[0]["text"] == "[Shared Telegram contact]"
|
||||
|
||||
|
||||
def test_system_message_preserves_existing_handoff_state(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
enqueue_calls: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_ai_enqueue_request",
|
||||
lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)),
|
||||
)
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_system_handoff", "text": "Need human"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
thread.ai_state = "handoff_required"
|
||||
thread.ai_handoff_reason = "Existing handoff"
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
enqueue_calls.clear()
|
||||
accepted = telegram_client.post(
|
||||
"/integrations/telegram/bot/webhook",
|
||||
json={
|
||||
"message": {
|
||||
"message_id": 29,
|
||||
"chat": {"id": "chat_system_handoff"},
|
||||
"from": {"id": 93, "username": "photo_user"},
|
||||
"photo": [{"file_id": "abc"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
assert enqueue_calls == []
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
assert thread.ai_state == "handoff_required"
|
||||
assert thread.ai_handoff_reason == "Existing handoff"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_bot_webhook_duplicate_message_id_is_idempotent():
|
||||
telegram_client = TestClient(telegram_app)
|
||||
payload = {
|
||||
"update_id": 9015,
|
||||
"message": {
|
||||
"message_id": 5150,
|
||||
"chat": {"id": 55150},
|
||||
"from": {"id": 7715, "username": "duplicate_user"},
|
||||
"text": "Duplicate payload",
|
||||
},
|
||||
}
|
||||
|
||||
first = telegram_client.post("/integrations/telegram/bot/webhook", json=payload)
|
||||
second = telegram_client.post("/integrations/telegram/bot/webhook", json=payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["thread_id"] == second.json()["thread_id"]
|
||||
assert first.json()["interaction_id"] == second.json()["interaction_id"]
|
||||
assert first.json()["message_id"] == second.json()["message_id"]
|
||||
assert first.json()["thread_created"] is True
|
||||
assert second.json()["thread_created"] is False
|
||||
|
||||
messages = telegram_client.get(
|
||||
f"/integrations/telegram/threads/{first.json()['thread_id']}/messages",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert messages.status_code == 200
|
||||
assert len(messages.json()) == 1
|
||||
|
||||
|
||||
def test_reply_after_close_is_conflict_and_close_clears_claim(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
telegram_module,
|
||||
"_send_telegram_message",
|
||||
lambda chat_id, text: (_ for _ in ()).throw(AssertionError("sendMessage should not be called")),
|
||||
)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_close_reply", "text": "Need closure"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_close"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
|
||||
closed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/close",
|
||||
headers=operator_headers("operator_close"),
|
||||
)
|
||||
assert closed.status_code == 200
|
||||
assert closed.json()["status"] == "closed"
|
||||
assert closed.json()["claimed_by_user"] is None
|
||||
assert closed.json()["claimed_at"] is None
|
||||
|
||||
reply = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/messages",
|
||||
headers=operator_headers("operator_close"),
|
||||
json={"text": "Should fail"},
|
||||
)
|
||||
assert reply.status_code == 409
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
messages = session.execute(
|
||||
select(TelegramMessageRow).where(TelegramMessageRow.thread_id == thread_id)
|
||||
).scalars().all()
|
||||
assert thread.status == "closed"
|
||||
assert thread.claimed_by_user is None
|
||||
assert thread.claimed_at is None
|
||||
assert len(messages) == 1
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_ai_handoff_is_rejected_for_human_owned_thread(monkeypatch):
|
||||
patch_interaction_request(monkeypatch)
|
||||
telegram_client = TestClient(telegram_app)
|
||||
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_ai_handoff_locked", "text": "Need operator"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
claimed = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_handoff_lock"),
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
assert claimed.json()["ai_state"] == "human_owned"
|
||||
|
||||
handoff = telegram_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/ai/handoff",
|
||||
headers=admin_headers(),
|
||||
json={"reason": "Late AI handoff"},
|
||||
)
|
||||
assert handoff.status_code == 409
|
||||
assert handoff.json()["detail"] == "Telegram thread is owned by a human operator"
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
assert thread.ai_state == "human_owned"
|
||||
assert thread.claimed_by_user == "operator_handoff_lock"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_claim_race_returns_single_winner(monkeypatch):
|
||||
entered_assign = threading.Event()
|
||||
release_assign = threading.Event()
|
||||
assign_calls: list[str] = []
|
||||
assign_lock = threading.Lock()
|
||||
|
||||
def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict:
|
||||
if path.endswith("/assign"):
|
||||
with assign_lock:
|
||||
assign_calls.append(payload["assignee"])
|
||||
if len(assign_calls) == 1:
|
||||
entered_assign.set()
|
||||
assert release_assign.wait(5)
|
||||
|
||||
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 = telegram_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(telegram_module, "_interaction_request", fake_request)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
created = telegram_client.post(
|
||||
"/integrations/telegram/webhook",
|
||||
json={"chat_id": "chat_claim_race", "text": "Race me"},
|
||||
)
|
||||
thread_id = created.json()["thread_id"]
|
||||
|
||||
winner: dict[str, object] = {}
|
||||
|
||||
def run_first_claim() -> None:
|
||||
first_client = TestClient(telegram_app)
|
||||
try:
|
||||
winner["response"] = first_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_a"),
|
||||
)
|
||||
finally:
|
||||
first_client.close()
|
||||
|
||||
claim_thread = threading.Thread(target=run_first_claim)
|
||||
claim_thread.start()
|
||||
|
||||
assert entered_assign.wait(5)
|
||||
|
||||
loser_client = TestClient(telegram_app)
|
||||
try:
|
||||
loser = loser_client.post(
|
||||
f"/integrations/telegram/threads/{thread_id}/claim",
|
||||
headers=operator_headers("operator_b"),
|
||||
)
|
||||
finally:
|
||||
loser_client.close()
|
||||
|
||||
release_assign.set()
|
||||
claim_thread.join(timeout=5)
|
||||
|
||||
first = winner["response"]
|
||||
assert getattr(first, "status_code") == 200
|
||||
assert loser.status_code == 409
|
||||
assert assign_calls == ["operator_a"]
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one()
|
||||
assert thread.claimed_by_user == "operator_a"
|
||||
assert thread.status == "in_progress"
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user