gendesign/tradein-mvp/backend/tests/services/tgbot/test_bridge.py
bot-backend b579fa4ced
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
feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
Клиент пишет боту в личку → воркер зеркалит сообщение через 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).
2026-07-16 16:58:53 +03:00

654 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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