H1 (critical): reorder send_support_message — get_or_create_thread now runs AFTER a successful Telegram send, not before. Prod runs a single uvicorn process with no --workers on a sync SQLAlchemy engine sharing one event loop; holding a row-lock across the Telegram call (which can legally take minutes on 429/5xx worker-grade retries) risked freezing the entire API on a second concurrent request from the same user. send_message now also accepts explicit timeout/max_retries so the interactive endpoint uses a bounded budget instead of inheriting the long-polling worker's retry policy. M1: web_support_messages and tg_support_messages both gain a support_chat_id column (188 migration for the already-applied tg_support_messages table; 187 edited in place since it hasn't shipped yet). bridge._handle_group_reply now resolves BOTH the Telegram and web candidate under the CURRENT support_chat_id and refuses delivery loudly (logger.error) if both match, instead of silently preferring the Telegram path — closing a cross-table misroute risk that would surface if the support group is ever recreated. M2: a media reply to a web-mirrored message (including photos with a caption) is now refused in full with an explicit notice back in the topic, instead of silently delivering just the caption text or logging a WARNING nobody sees. M3: list_support_messages/get_support_unread/mark_support_read switched from async def to def — their bodies are pure sync psycopg calls; running them as async def executed blocking DB work directly on the event loop. M5: list_messages now takes a bounded LIMIT (last N, chronological) so a long-lived thread doesn't return its entire history on every poll. L1-L4: warn when Telegram doesn't return a message_id (routing dead-end), rate limit is peeked before send and only recorded on success (failed attempts no longer burn the budget), SlidingWindowLimiter gained the same empty-bucket cleanup as RateLimitMiddleware, and _require_username now strips whitespace so a proxy-injected space can't fork a second thread. L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks applied migrations via _schema_migrations, not this file, and keeping an unmerged migration name out of it preserves the option to rename before merge without tripping the "can't rename applied migrations" test.
862 lines
38 KiB
Python
862 lines
38 KiB
Python
"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.
|
||
|
||
Coverage (per task spec + review follow-up):
|
||
- user → topic (личка клиента зеркалится в support-топик, с шапкой на первое
|
||
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
|
||
после истечения окна — #6 review)
|
||
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
||
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
|
||
в web-тред (скоуп по (topic_message_id, support_chat_id) — review M1), ответ
|
||
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
|
||
ПОДПИСЬЮ) на веб-зеркало — отказ ЦЕЛИКОМ + уведомление оператору в топике
|
||
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
|
||
устаревшего support_chat_id — не матчится (ротация группы); NULL
|
||
support_chat_id (легаси) — wildcard-матч; совпадение ОБЕИХ сторон
|
||
одновременно (tg И web) — громкий отказ (logger.error), а не молчаливый
|
||
выбор tg-пути (152-ФЗ misroute risk)
|
||
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
||
(#4 review)
|
||
- дедуп update_id (<=offset — skip без side-effects; poison-pill апдейт всё
|
||
равно сдвигает offset, чтобы не подвесить весь поток)
|
||
- сбой БД (SQLAlchemyError) во время обработки → rollback() ПЕРЕД save_offset,
|
||
offset всё равно сдвигается — без этого следующий поллинг переиграл бы тот
|
||
же апдейт и задублировал зеркало клиента в топике (#3 review)
|
||
- /start → приветствие без зеркалирования
|
||
- Telegram 403 на доставку оператору → is_blocked + уведомление в топике
|
||
- TELEGRAM_SUPPORT_CHAT_ID не задан → клиенту уходит "сервис недоступен"
|
||
вместо тихой потери сообщения (#5 review)
|
||
|
||
NEVER calls real Telegram API — все HTTP-запросы mock'аются через
|
||
httpx.MockTransport (consistent с tests/services/test_dadata.py).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
|
||
# DATABASE_URL required by app.core.config before any app import (см. test_dadata.py).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.tgbot import bridge
|
||
from app.services.tgbot.client import TelegramClient
|
||
|
||
SUPPORT_CHAT_ID = -100123456789
|
||
SUPPORT_TOPIC_ID = 42
|
||
|
||
|
||
# ── Fake in-memory storage (БД не нужна) ─────────────────────────────────────
|
||
class FakeBridgeStorage:
|
||
"""In-memory `BridgeStorage` — никакой реальной БД, чистая логика роутинга.
|
||
|
||
`clock_s` — управляемые тестом "фейковые часы" (просто float, продвигается
|
||
вручную через `storage.clock_s += ...`), чтобы честно проверить throttle-окно
|
||
в `had_recent_inbound` (#6 review) без реального `time.sleep`/datetime-моков.
|
||
|
||
`fail_next_record_message` — если True, следующий вызов `record_message`
|
||
кидает `SQLAlchemyError` (симулирует обрыв коннекта к БД) и сбрасывается в
|
||
False — для теста rollback-пути в `process_update` (#3 review).
|
||
"""
|
||
|
||
def __init__(self, offset: int = 0) -> None:
|
||
self._offset = offset
|
||
self.users: dict[int, dict[str, Any]] = {}
|
||
self.messages: list[dict[str, Any]] = []
|
||
self.blocked: set[int] = set()
|
||
self.commits = 0
|
||
self.rollbacks = 0
|
||
self._next_id = 1
|
||
self.clock_s: float = 0.0
|
||
self.fail_next_record_message = False
|
||
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
|
||
# (thread_id, support_chat_id) — второй элемент моделирует колонку
|
||
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
|
||
# + журнал outbound-записей, записанных через реплай оператора.
|
||
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
|
||
self.web_out_messages: list[dict[str, Any]] = []
|
||
|
||
def get_offset(self) -> int:
|
||
return self._offset
|
||
|
||
def save_offset(self, update_id: int) -> None:
|
||
self._offset = update_id
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def rollback(self) -> None:
|
||
self.rollbacks += 1
|
||
|
||
def upsert_user(
|
||
self,
|
||
*,
|
||
chat_id: int,
|
||
username: str | None,
|
||
first_name: str | None,
|
||
last_name: str | None,
|
||
language_code: str | None,
|
||
) -> None:
|
||
self.users[chat_id] = {
|
||
"username": username,
|
||
"first_name": first_name,
|
||
"last_name": last_name,
|
||
"language_code": language_code,
|
||
}
|
||
|
||
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
|
||
return any(
|
||
m["chat_id"] == chat_id
|
||
and m["direction"] == "in"
|
||
and (self.clock_s - m["recorded_at_s"]) < window_seconds
|
||
for m in self.messages
|
||
)
|
||
|
||
def record_message(
|
||
self,
|
||
*,
|
||
chat_id: int,
|
||
direction: str,
|
||
tg_message_id: int | None,
|
||
topic_message_id: int | None,
|
||
kind: str,
|
||
text_body: str | None,
|
||
operator_tg_id: int | None,
|
||
support_chat_id: int | None = None,
|
||
) -> int:
|
||
if self.fail_next_record_message:
|
||
self.fail_next_record_message = False
|
||
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
|
||
row_id = self._next_id
|
||
self._next_id += 1
|
||
self.messages.append(
|
||
{
|
||
"id": row_id,
|
||
"chat_id": chat_id,
|
||
"direction": direction,
|
||
"tg_message_id": tg_message_id,
|
||
"topic_message_id": topic_message_id,
|
||
"kind": kind,
|
||
"text_body": text_body,
|
||
"operator_tg_id": operator_tg_id,
|
||
"support_chat_id": support_chat_id,
|
||
"recorded_at_s": self.clock_s,
|
||
}
|
||
)
|
||
return row_id
|
||
|
||
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
|
||
support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
|
||
for m in reversed(self.messages):
|
||
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
|
||
continue
|
||
entry_chat_id = m.get("support_chat_id")
|
||
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||
continue
|
||
return m["chat_id"]
|
||
return None
|
||
|
||
def mark_blocked(self, chat_id: int) -> None:
|
||
self.blocked.add(chat_id)
|
||
|
||
# ── #tgsupport-web ────────────────────────────────────────────────────
|
||
def find_web_thread_by_topic_message(
|
||
self, topic_message_id: int, support_chat_id: int
|
||
) -> int | None:
|
||
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
|
||
entry = self.web_topic_to_thread.get(topic_message_id)
|
||
if entry is None:
|
||
return None
|
||
thread_id, entry_chat_id = entry
|
||
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||
return None
|
||
return thread_id
|
||
|
||
def record_web_out_message(
|
||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||
) -> None:
|
||
self.web_out_messages.append(
|
||
{
|
||
"thread_id": thread_id,
|
||
"text_body": text_body,
|
||
"operator_tg_id": operator_tg_id,
|
||
}
|
||
)
|
||
|
||
|
||
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||
|
||
|
||
def _method_from_url(url: httpx.URL) -> str:
|
||
return str(url).rsplit("/", 1)[-1]
|
||
|
||
|
||
def _make_client(
|
||
responses: dict[str, Any], calls: list[tuple[str, dict[str, Any]]]
|
||
) -> TelegramClient:
|
||
"""TelegramClient wired to a MockTransport. `responses[method]` may be a dict
|
||
(returned as Bot API `result`), an int (HTTP error status), or a callable
|
||
`(payload) -> dict`. Every request is recorded into `calls`."""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json as _json
|
||
|
||
method = _method_from_url(request.url)
|
||
payload = _json.loads(request.content.decode("utf-8")) if request.content else {}
|
||
calls.append((method, payload))
|
||
|
||
canned = responses.get(method)
|
||
if isinstance(canned, int):
|
||
return httpx.Response(
|
||
canned, json={"ok": False, "error_code": canned, "description": "mocked error"}
|
||
)
|
||
if callable(canned):
|
||
canned = canned(payload)
|
||
result = canned if canned is not None else {"message_id": 999}
|
||
return httpx.Response(200, json={"ok": True, "result": result})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||
|
||
client = TelegramClient(token="fake-token")
|
||
import unittest.mock as mock
|
||
|
||
# Патчим httpx.AsyncClient ТОЛЬКО внутри client-модуля — не трогаем глобальный httpx.
|
||
patcher = mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory)
|
||
patcher.start()
|
||
return client
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _support_chat_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Все тесты по умолчанию считают support-группу/топик настроенными."""
|
||
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", SUPPORT_CHAT_ID)
|
||
monkeypatch.setattr(bridge.settings, "telegram_support_topic_id", SUPPORT_TOPIC_ID)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _stop_patches():
|
||
"""Останавливает httpx.AsyncClient monkeypatch после каждого теста (unittest.mock.patch.start()
|
||
без контекст-менеджера требует явного stop, чтобы не утекать в соседние тесты)."""
|
||
import unittest.mock as mock
|
||
|
||
yield
|
||
mock.patch.stopall()
|
||
|
||
|
||
def _private_message(
|
||
*,
|
||
message_id: int = 1,
|
||
chat_id: int = 555,
|
||
text: str | None = "Здравствуйте, вопрос по trade-in",
|
||
username: str | None = "client_ivan",
|
||
first_name: str | None = "Иван",
|
||
last_name: str | None = "Петров",
|
||
) -> dict[str, Any]:
|
||
msg: dict[str, Any] = {
|
||
"message_id": message_id,
|
||
"chat": {"id": chat_id, "type": "private"},
|
||
"from": {
|
||
"id": chat_id,
|
||
"username": username,
|
||
"first_name": first_name,
|
||
"last_name": last_name,
|
||
"language_code": "ru",
|
||
},
|
||
}
|
||
if text is not None:
|
||
msg["text"] = text
|
||
return msg
|
||
|
||
|
||
def _group_reply_message(
|
||
*,
|
||
message_id: int = 200,
|
||
reply_to_message_id: int | None = 100,
|
||
text: str = "Ответ оператора",
|
||
operator_id: int = 777,
|
||
) -> dict[str, Any]:
|
||
msg: dict[str, Any] = {
|
||
"message_id": message_id,
|
||
"chat": {"id": SUPPORT_CHAT_ID, "type": "supergroup"},
|
||
"from": {"id": operator_id, "username": "operator1"},
|
||
"text": text,
|
||
}
|
||
if reply_to_message_id is not None:
|
||
msg["reply_to_message"] = {"message_id": reply_to_message_id}
|
||
return msg
|
||
|
||
|
||
# ── A) user → topic ──────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_private_message_mirrors_to_topic_with_header_on_first_contact() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 555}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
update = {"update_id": 10, "message": _private_message()}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
# Первое сообщение за окно → шапка ПЕРЕД зеркалом контента.
|
||
assert methods == ["sendMessage", "copyMessage"]
|
||
|
||
header_call = calls[0][1]
|
||
assert header_call["chat_id"] == SUPPORT_CHAT_ID
|
||
assert header_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||
assert "Иван Петров" in header_call["text"]
|
||
assert "@client_ivan" in header_call["text"]
|
||
|
||
mirror_call = calls[1][1]
|
||
assert mirror_call["from_chat_id"] == 555
|
||
assert mirror_call["chat_id"] == SUPPORT_CHAT_ID
|
||
assert mirror_call["message_id"] == 1
|
||
assert mirror_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||
|
||
assert len(storage.messages) == 1
|
||
rec = storage.messages[0]
|
||
assert rec["direction"] == "in"
|
||
assert rec["chat_id"] == 555
|
||
assert rec["topic_message_id"] == 555 # copyMessage result.message_id
|
||
assert rec["kind"] == "text"
|
||
assert rec["text_body"] == "Здравствуйте, вопрос по trade-in"
|
||
|
||
assert storage.get_offset() == 10
|
||
assert storage.commits == 1
|
||
assert 555 in storage.users
|
||
|
||
|
||
async def test_private_message_second_message_within_window_skips_header() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 556}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
# Симулируем уже существующее inbound-сообщение за последний час.
|
||
storage.record_message(
|
||
chat_id=555,
|
||
direction="in",
|
||
tg_message_id=0,
|
||
topic_message_id=100,
|
||
kind="text",
|
||
text_body="первое сообщение",
|
||
operator_tg_id=None,
|
||
)
|
||
|
||
update = {"update_id": 11, "message": _private_message(message_id=2)}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
# Шапка НЕ отправляется повторно — только зеркало.
|
||
assert methods == ["copyMessage"]
|
||
assert len(storage.messages) == 2
|
||
|
||
|
||
async def test_private_message_header_resent_after_window_expires() -> None:
|
||
"""#6 review: throttle-окно (3600с) реально проверяется по времени — после
|
||
истечения окна шапка отправляется заново (не одна на весь чат навсегда)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 557}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.record_message(
|
||
chat_id=555,
|
||
direction="in",
|
||
tg_message_id=0,
|
||
topic_message_id=100,
|
||
kind="text",
|
||
text_body="первое сообщение (час назад)",
|
||
operator_tg_id=None,
|
||
)
|
||
# Продвигаем фейковые часы за throttle-окно (3600с).
|
||
storage.clock_s += bridge._HEADER_THROTTLE_WINDOW_S + 1
|
||
|
||
update = {"update_id": 13, "message": _private_message(message_id=3)}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["sendMessage", "copyMessage"] # шапка снова отправлена
|
||
assert len(storage.messages) == 2
|
||
|
||
|
||
async def test_private_message_start_sends_greeting_without_mirroring() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
update = {"update_id": 12, "message": _private_message(text="/start")}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["sendMessage"]
|
||
greeting_call = calls[0][1]
|
||
assert greeting_call["chat_id"] == 555
|
||
assert "МЕРА" in greeting_call["text"]
|
||
# /start не зеркалируется и не попадает в лог переписки.
|
||
assert storage.messages == []
|
||
assert storage.get_offset() == 12
|
||
|
||
|
||
async def test_private_message_notifies_client_when_support_chat_unset(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""#5 review: TELEGRAM_SUPPORT_CHAT_ID не задан → клиент получает "сервис
|
||
недоступен" вместо того, чтобы молча ждать ответа, который никогда не придёт."""
|
||
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", 0)
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
update = {"update_id": 14, "message": _private_message()}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["sendMessage"]
|
||
notify_call = calls[0][1]
|
||
assert notify_call["chat_id"] == 555
|
||
assert notify_call["text"] == bridge.SERVICE_UNAVAILABLE_TEXT
|
||
# Ничего не зеркалируется и не пишется в лог переписки — support-группа не настроена.
|
||
assert storage.messages == []
|
||
assert storage.get_offset() == 14
|
||
|
||
|
||
# ── B) реплай оператора → user ──────────────────────────────────────────────
|
||
|
||
|
||
async def test_group_reply_delivers_to_client_and_records_outbound() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.record_message(
|
||
chat_id=555,
|
||
direction="in",
|
||
tg_message_id=1,
|
||
topic_message_id=100,
|
||
kind="text",
|
||
text_body="вопрос клиента",
|
||
operator_tg_id=None,
|
||
)
|
||
|
||
update = {
|
||
"update_id": 20,
|
||
"message": _group_reply_message(reply_to_message_id=100),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["copyMessage"]
|
||
delivery = calls[0][1]
|
||
assert delivery["chat_id"] == 555
|
||
assert delivery["from_chat_id"] == SUPPORT_CHAT_ID
|
||
|
||
assert len(storage.messages) == 2
|
||
out_rec = storage.messages[-1]
|
||
assert out_rec["direction"] == "out"
|
||
assert out_rec["chat_id"] == 555
|
||
assert out_rec["operator_tg_id"] == 777
|
||
assert out_rec["text_body"] == "Ответ оператора"
|
||
assert storage.get_offset() == 20
|
||
|
||
|
||
async def test_group_reply_not_a_reply_is_ignored() -> None:
|
||
"""Обычное сообщение в топике (не реплай) — тихий игнор, никаких Telegram-вызовов."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
update = {
|
||
"update_id": 21,
|
||
"message": _group_reply_message(reply_to_message_id=None),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.messages == []
|
||
# offset всё равно сдвигается — апдейт "обработан" (даже если ничего не сделано).
|
||
assert storage.get_offset() == 21
|
||
|
||
|
||
async def test_group_reply_to_unknown_message_is_ignored() -> None:
|
||
"""Реплай на сообщение, которого нет в tg_support_messages как зеркало клиента —
|
||
тихий игнор (обычная болтовня в топике на постороннее сообщение, не от бота)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
update = {
|
||
"update_id": 22,
|
||
"message": _group_reply_message(reply_to_message_id=999999),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.messages == []
|
||
assert storage.get_offset() == 22
|
||
|
||
|
||
async def test_group_reply_to_bot_message_without_record_logs_orphaned_mirror_warning(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""#4 review: реплай на сообщение БОТА, которого нет в tg_support_messages, —
|
||
вероятное осиротевшее зеркало (крах между copyMessage и commit). WARNING, не
|
||
тихий игнор — оператор иначе решит, что ответ клиенту доставлен."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
message = _group_reply_message(reply_to_message_id=100)
|
||
message["reply_to_message"]["from"] = {"id": 999, "is_bot": True, "username": "MERAsupport_bot"}
|
||
update = {"update_id": 24, "message": message}
|
||
|
||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == [] # ответ НЕ доставлен — routing-ключ потерян
|
||
assert "осиротевшее" in caplog.text
|
||
assert storage.get_offset() == 24
|
||
|
||
|
||
async def test_group_reply_to_non_bot_message_without_record_stays_silent(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""Обычный реплай на сообщение ДРУГОГО ЧЕЛОВЕКА (не бота) в топике — реальная
|
||
болтовня, никакого WARNING (дискриминатор `is_bot` работает в обе стороны)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
message = _group_reply_message(reply_to_message_id=101)
|
||
message["reply_to_message"]["from"] = {"id": 42, "is_bot": False, "username": "colleague"}
|
||
update = {"update_id": 25, "message": message}
|
||
|
||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert caplog.text == ""
|
||
|
||
|
||
async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": 403}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.record_message(
|
||
chat_id=555,
|
||
direction="in",
|
||
tg_message_id=1,
|
||
topic_message_id=100,
|
||
kind="text",
|
||
text_body="вопрос клиента",
|
||
operator_tg_id=None,
|
||
)
|
||
|
||
update = {
|
||
"update_id": 23,
|
||
"message": _group_reply_message(reply_to_message_id=100),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["copyMessage", "sendMessage"]
|
||
notify_call = calls[1][1]
|
||
assert notify_call["chat_id"] == SUPPORT_CHAT_ID
|
||
assert "заблокирован" in notify_call["text"]
|
||
|
||
assert 555 in storage.blocked
|
||
# Неудачная доставка НЕ должна создавать фейковую запись 'out'.
|
||
assert len(storage.messages) == 1
|
||
assert storage.get_offset() == 23
|
||
|
||
|
||
# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────
|
||
|
||
|
||
async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
|
||
"""Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
|
||
среди web_support_messages) → записывается в веб-тред, БЕЗ Telegram-доставки
|
||
(у веб-клиента нет личного чата с ботом)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
|
||
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
|
||
|
||
update = {
|
||
"update_id": 60,
|
||
"message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
# Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
|
||
assert calls == []
|
||
assert len(storage.web_out_messages) == 1
|
||
rec = storage.web_out_messages[0]
|
||
assert rec["thread_id"] == 42
|
||
assert rec["text_body"] == "Ответ по веб-чату"
|
||
assert rec["operator_tg_id"] == 777
|
||
# tg-путь тоже не тронут — ни одной записи в tg_support_messages.
|
||
assert storage.messages == []
|
||
assert storage.get_offset() == 60
|
||
|
||
|
||
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
|
||
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
|
||
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.web_topic_to_thread[305] = (46, None)
|
||
|
||
update = {
|
||
"update_id": 63,
|
||
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert len(storage.web_out_messages) == 1
|
||
assert storage.web_out_messages[0]["thread_id"] == 46
|
||
|
||
|
||
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
|
||
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
|
||
не None) support_chat_id — исторический артефакт ротации группы, НЕ валидный
|
||
маршрут сегодня. Не матчится → падает в orphan-check (не-bot реплай — тихий
|
||
игнор, никакой доставки в чужой/устаревший тред)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
stale_chat_id = -999999999999
|
||
storage.web_topic_to_thread[306] = (47, stale_chat_id)
|
||
|
||
update = {
|
||
"update_id": 64,
|
||
"message": _group_reply_message(reply_to_message_id=306),
|
||
}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
|
||
|
||
|
||
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
|
||
не может быть доставлен — WARNING в лог И явное уведомление оператору в топике
|
||
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
|
||
|
||
message = _group_reply_message(reply_to_message_id=301, message_id=201)
|
||
del message["text"] # медиа-реплай без текста/caption
|
||
message["voice"] = {"file_id": "x"}
|
||
update = {"update_id": 61, "message": message}
|
||
|
||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert storage.web_out_messages == []
|
||
assert "не текст" in caplog.text
|
||
assert storage.get_offset() == 61
|
||
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["sendMessage"]
|
||
notice_call = calls[0][1]
|
||
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
|
||
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||
assert notice_call["reply_to_message_id"] == 201
|
||
|
||
|
||
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
|
||
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
|
||
(клиент решил бы, что подпись — весь ответ): отказ целиком, как и без caption
|
||
(review M2)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
|
||
|
||
message = _group_reply_message(reply_to_message_id=302, message_id=202)
|
||
del message["text"]
|
||
message["photo"] = [{"file_id": "x"}]
|
||
message["caption"] = "Смотрите скриншот"
|
||
update = {"update_id": 65, "message": message}
|
||
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
|
||
methods = [m for m, _ in calls]
|
||
assert methods == ["sendMessage"]
|
||
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||
|
||
|
||
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
|
||
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
|
||
support_chat_id — целостность нарушена) — ГРОМКИЙ отказ (logger.error), НИКАКОЙ
|
||
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча —
|
||
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.record_message(
|
||
chat_id=555,
|
||
direction="in",
|
||
tg_message_id=1,
|
||
topic_message_id=400,
|
||
kind="text",
|
||
text_body="вопрос клиента",
|
||
operator_tg_id=None,
|
||
support_chat_id=SUPPORT_CHAT_ID,
|
||
)
|
||
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
|
||
|
||
update = {
|
||
"update_id": 62,
|
||
"message": _group_reply_message(reply_to_message_id=400),
|
||
}
|
||
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
|
||
assert storage.web_out_messages == []
|
||
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
|
||
assert "ОДНОВРЕМЕННО" in caplog.text
|
||
assert storage.get_offset() == 62
|
||
|
||
|
||
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_dedup_update_id_leq_offset_is_skipped_without_side_effects() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage(offset=50)
|
||
|
||
update = {"update_id": 50, "message": _private_message()}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.messages == []
|
||
assert storage.commits == 0 # ранний return — offset уже актуален, коммитить нечего
|
||
assert storage.get_offset() == 50
|
||
|
||
update_older = {"update_id": 10, "message": _private_message()}
|
||
await bridge.process_update(update_older, client, storage)
|
||
assert calls == []
|
||
assert storage.get_offset() == 50
|
||
|
||
|
||
async def test_poison_pill_update_still_advances_offset() -> None:
|
||
"""Апдейт, на котором обработчик упал (например, message без chat), не должен
|
||
подвесить весь поток — offset сдвигается даже при исключении внутри handler'а."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
malformed_message = {"message_id": 1, "chat": {"type": "private"}} # нет chat.id
|
||
update = {"update_id": 30, "message": malformed_message}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert storage.get_offset() == 30
|
||
assert storage.commits == 1
|
||
assert storage.messages == []
|
||
|
||
|
||
async def test_db_error_during_processing_rolls_back_and_still_advances_offset() -> None:
|
||
"""#3 review: SQLAlchemyError (напр. обрыв коннекта к БД при деплое) во время
|
||
`record_message` → storage.rollback() ПЕРЕД save_offset, offset всё равно
|
||
сдвигается. Без rollback() save_offset сам кинул бы PendingRollbackError →
|
||
process_update вылетел бы без сохранения offset'а → следующая итерация
|
||
переиграла бы тот же апдейт → copyMessage задублировал бы зеркало в топике
|
||
на каждый повтор поллинга (воспроизведено ревьюером)."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({"copyMessage": {"message_id": 558}}, calls)
|
||
storage = FakeBridgeStorage()
|
||
storage.fail_next_record_message = True
|
||
|
||
update = {"update_id": 15, "message": _private_message()}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
# copyMessage успел уйти в Telegram (реальная утечка мирроринга при DB-сбое —
|
||
# известное ограничение атомарности между внешним API и БД, вне scope этого фикса),
|
||
# но rollback() отработал, offset сдвинут, commit вызван РОВНО один раз (в finally).
|
||
assert storage.rollbacks == 1
|
||
assert storage.commits == 1
|
||
assert storage.get_offset() == 15
|
||
# Запись сообщения НЕ попала в storage (record_message упал до append).
|
||
assert storage.messages == []
|
||
|
||
# Повторный вызов с тем же update_id теперь корректно дедупится — НЕ переигрывается.
|
||
calls.clear()
|
||
await bridge.process_update(update, client, storage)
|
||
assert calls == []
|
||
assert storage.get_offset() == 15
|
||
|
||
|
||
async def test_update_without_update_id_is_ignored() -> None:
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
await bridge.process_update({"message": _private_message()}, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.commits == 0
|
||
assert storage.get_offset() == 0
|
||
|
||
|
||
# ── kind inference ────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("message_extra", "expected_kind"),
|
||
[
|
||
({"text": "hi"}, "text"),
|
||
({"photo": [{"file_id": "x"}]}, "photo"),
|
||
({"document": {"file_id": "x"}}, "document"),
|
||
({"video": {"file_id": "x"}}, "video"),
|
||
({"voice": {"file_id": "x"}}, "voice"),
|
||
({"sticker": {"file_id": "x"}}, "other"),
|
||
({"location": {"latitude": 1, "longitude": 2}}, "other"),
|
||
({}, "other"),
|
||
],
|
||
)
|
||
def test_infer_kind(message_extra: dict[str, Any], expected_kind: str) -> None:
|
||
message = {"message_id": 1, "chat": {"id": 1, "type": "private"}, **message_extra}
|
||
assert bridge._infer_kind(message) == expected_kind
|
||
|
||
|
||
# ── unrelated chat types ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> None:
|
||
"""Апдейт не из личного чата и не из support-группы (например, другой чат/канал)
|
||
— молча игнорируется, offset всё равно сдвигается."""
|
||
calls: list[tuple[str, dict[str, Any]]] = []
|
||
client = _make_client({}, calls)
|
||
storage = FakeBridgeStorage()
|
||
|
||
message = {
|
||
"message_id": 1,
|
||
"chat": {"id": -999, "type": "group"},
|
||
"from": {"id": 1},
|
||
"text": "болтовня в постороннем чате",
|
||
}
|
||
update = {"update_id": 40, "message": message}
|
||
await bridge.process_update(update, client, storage)
|
||
|
||
assert calls == []
|
||
assert storage.messages == []
|
||
assert storage.get_offset() == 40
|