All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот доставляет ответ клиенту. Полный лог переписки в Postgres. Отдельный контейнер на long-polling, а не webhook в tradein-backend: не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API. Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage. Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате, поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор. Безопасность (найдено ревью, воспроизведено эмпирически): - токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет; утекал в GlitchTip через locals стек-фреймов (include_local_variables по умолчанию True) и через span data HttpxIntegration. Закрыто include_local_variables=False + regex-редактор в before_send (обе формы: /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub. - httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в docker logs каждые 30с. Приглушён до WARNING. Надёжность: - kill-switch при пустом токене — idle-блокировка, не exit(0): при restart: unless-stopped выход с любым кодом даёт рестарт-луп. unless-stopped выбран сознательно — только он гарантирует автозапуск после ребута VPS. - stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше, чем докрутится long-poll (30с) и отработает drain (100с). - сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset: иначе сессия в failed-transaction не давала сохранить offset, апдейт переигрывался и зеркалился в топик по кругу. 152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента одним DELETE. Ретенция — follow-up. Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS, без них воркер штатно висит в idle. Порядок — в DEPLOY.md. Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked, throttle-окно шапки, redaction токена во всех формах event).
654 lines
27 KiB
Python
654 lines
27 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')
|
||
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||
реплай на СООБЩЕНИЕ БОТА без записи в БД — 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
|
||
|
||
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,
|
||
) -> 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,
|
||
"recorded_at_s": self.clock_s,
|
||
}
|
||
)
|
||
return row_id
|
||
|
||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
||
for m in reversed(self.messages):
|
||
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id:
|
||
return m["chat_id"]
|
||
return None
|
||
|
||
def mark_blocked(self, chat_id: int) -> None:
|
||
self.blocked.add(chat_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
|
||
|
||
|
||
# ── 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
|