diff --git a/maxapi/types/message.py b/maxapi/types/message.py index 9dd8f37..62b8a79 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -22,6 +22,7 @@ Underline, UserMention, ) +from ..utils.message_link import build_message_link from .users import User if TYPE_CHECKING: @@ -320,17 +321,27 @@ class Message( Текст + вложения. Может быть null, если сообщение содержит только пересланное сообщение stat (Optional[MessageStat]): Статистика сообщения. Может быть None. - url (Optional[str]): URL сообщения. Может быть None. + url_api (Optional[str]): URL сообщения из ответа API. + Публичная ссылка на пост в канале. + Отсутствует для диалогов и групповых чатов + url (Optional[str]): Генерируемое свойство URL сообщения. + Дополняет ответ API для приватных чатов и групп + Может быть None в случае отсутствия body. bot (Optional[Bot]): Объект бота, исключается из сериализации. """ + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) sender: User | None = None recipient: Recipient timestamp: int link: LinkedMessage | None = None body: MessageBody | None = None stat: MessageStat | None = None - url: str | None = None + url_api: str | None = Field( + # Поле для хранения сырого url из ответа API + alias="url", + default=None, + ) bot: Any | None = Field( # pyright: ignore[reportRedeclaration] default=None, exclude=True ) @@ -631,6 +642,25 @@ async def unpin(self) -> DeletedPinMessage: chat_id=self.recipient.chat_id, ) + @property + def url(self) -> str | None: + """ + Прямая ссылка на сообщение в интерфейсе MAX + + Returns: + str: Ссылка на сообщение в формате + - Для диалогов и групповых чатов: + https://max.ru/c/{chat_id}/{seq_b64} + - Постов в канале: + https://max.ru/{channel_name}/{seq_b64} + None: Если объект Message не содержит в себе body + """ + if self.url_api: + return self.url_api + elif self.body: + return build_message_link(self.body.mid) + return None + class Messages(BaseModel): """ diff --git a/maxapi/utils/__init__.py b/maxapi/utils/__init__.py index e69de29..015988c 100644 --- a/maxapi/utils/__init__.py +++ b/maxapi/utils/__init__.py @@ -0,0 +1,13 @@ +from .message_link import ( + build_message_link, + chatid_seq_to_mid, + link_to_chatid_seq, + mid_to_chatid_seq, +) + +__all__ = [ + "build_message_link", + "chatid_seq_to_mid", + "link_to_chatid_seq", + "mid_to_chatid_seq", +] diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py new file mode 100644 index 0000000..214dfeb --- /dev/null +++ b/maxapi/utils/message_link.py @@ -0,0 +1,188 @@ +import base64 +import binascii +import re +from urllib.parse import urlparse + + +def _validate_chat_id(chat_id: int) -> None: + """Проверяет, что chat_id в диапазоне signed int64.""" + if chat_id < -(1 << 63) or chat_id >= (1 << 63): + raise ValueError( + "chat_id выходит за пределы знакового 64-битного диапазона" + ) + + +def mid_to_chatid_seq(mid: str) -> tuple[int, int]: + """ + Декодирует строку mid в пару (chat_id, seq). + + Формат mid: "mid." + 32 hex-символа, где: + - первые 16 символов: chat_id (signed int64, stored as unsigned hex) + - последние 16 символов: seq (unsigned int64) + + Args: + mid (str): Строка формата "mid.{32 hex символа}" + + Returns: + tuple[int, int]: Кортеж (chat_id, seq), + где chat_id — signed, seq — unsigned + + Raises: + TypeError: Если mid не является строкой + ValueError: Если mid не соответствует ожидаемому формату + """ + if not isinstance(mid, str): + raise TypeError("mid должен быть строкой") + + if not re.fullmatch(r"mid\.[0-9a-fA-F]{32}", mid): + raise ValueError('mid должен быть в формате "mid." + 32 hex-символа') + + hex_part = mid[4:] + + # Первые 16 символов — chat_id. MAX хранит его как signed 64-bit, + # но в hex он представлен как unsigned. Конвертируем обратно в signed. + chat_id_unsigned = int(hex_part[:16], 16) + if chat_id_unsigned >= (1 << 63): + chat_id = chat_id_unsigned - (1 << 64) + else: + chat_id = chat_id_unsigned + + # Последние 16 символов — seq. Всегда положительное 64-bit число. + seq = int(hex_part[16:], 16) + + return chat_id, seq + + +def chatid_seq_to_mid(chat_id: int, seq: int) -> str: + """ + Создаёт валидную строку mid из chat_id и seq. + + Формат результата: "mid." + 32 hex-символа (16 для chat_id + 16 для seq) + + Args: + chat_id (int): ID чата (signed int64, диапазон: -(2**63) .. 2**63-1) + seq (int): Порядковый номер сообщения + (unsigned int64, диапазон: 0..2**64-1) + + Returns: + str: Строка mid формата "mid.{32 hex символа}" + + Raises: + TypeError: Если chat_id или seq не являются int + ValueError: Если chat_id или seq выходят за допустимые диапазоны + """ + if not isinstance(chat_id, int): + raise TypeError("chat_id должен быть целым числом") + if not isinstance(seq, int): + raise TypeError("seq должен быть целым числом") + + _validate_chat_id(chat_id) + if seq < 0 or seq >= (1 << 64): + raise ValueError( + "seq выходит за пределы беззнакового 64-битного диапазона" + ) + + # Битовая маска гарантирует корректное hex-представление для signed int + # (отрицательные числа автоматически преобразуются в two's complement) + chat_id_hex = f"{chat_id & 0xFFFFFFFFFFFFFFFF:016x}" + seq_hex = f"{seq:016x}" + + return f"mid.{chat_id_hex}{seq_hex}" + + +def build_message_link(mid: str) -> str: + """ + Генерирует прямую ссылку на сообщение в интерфейсе MAX. + + Args: + mid (str): Значение из message.body.mid + + Returns: + str: URL ссылка на сообщение в интерфейсе приложения MAX. + Формат: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} + + Raises: + TypeError: Если mid не строка + ValueError: Если mid не соответствует формату "mid." + 32 hex-символа + """ + + chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь + + # 1. Преобразуем seq в 8 байт (big-endian) + seq_bytes = seq.to_bytes(8, byteorder="big") + # 2. Кодируем в URL-safe Base64 и убираем символы дополнения "=" + seq_b64 = base64.urlsafe_b64encode(seq_bytes).decode("ascii").rstrip("=") + + return f"https://max.ru/c/{chat_id}/{seq_b64}" + + +def link_to_chatid_seq(link: str) -> tuple[int, int]: + """ + Парсит ссылку на сообщение в интерфейсе MAX и извлекает chat_id и seq. + + Не обрабатываются ссылки на публичные каналы вида + https://max.ru/{channel_name}/{urlsafe_base64} + Только приватные чаты и группы. + + Args: + link (str): Ссылка формата https://max.ru/c/{chat_id}/{seq_b64} + + Returns: + tuple[int, int]: (chat_id, seq) + + Raises: + TypeError: Если link не строка + ValueError: Если ссылка невалидна / ссылка на канал (chat_id не число) + """ + # Валидация типа + if not isinstance(link, str): + raise TypeError("link должен быть строкой") + + parsed = urlparse(link) + + # Валидация схемы и домена + if parsed.scheme != "https": + raise ValueError("Ссылка должна использовать https схему") + if parsed.netloc != "max.ru": + raise ValueError("Ссылка должна указывать на домен max.ru") + + # Валидация пути: /c/{chat_id}/{seq_b64} + path_parts = parsed.path.strip("/").split("/") + if len(path_parts) != 3 or path_parts[0] != "c": + raise ValueError( + "Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}" + ) + + # Извлечение и валидация chat_id + try: + chat_id = int(path_parts[1]) + except ValueError as e: + raise ValueError("chat_id в ссылке должен быть целым числом") from e + + _validate_chat_id(chat_id) + + # Извлечение seq_b64 + seq_b64 = path_parts[2] + + if not seq_b64 or not re.fullmatch(r"[A-Za-z0-9_-]+", seq_b64): + raise ValueError("seq в ссылке должен быть в url-safe base64 формате") + + # Добавляем паддинг для корректного декодирования base64 + # Длина base64 должна быть кратна 4 + padding_needed = (4 - len(seq_b64) % 4) % 4 + seq_b64_padded = seq_b64 + "=" * padding_needed + + try: + # Декодируем из url-safe base64 + seq_bytes = base64.urlsafe_b64decode(seq_b64_padded) + except (binascii.Error, ValueError) as e: + raise ValueError(f"Ошибка декодирования base64: {e}") from e + + # Валидация длины: seq должен быть 8 байт (64 бита) + if len(seq_bytes) != 8: + raise ValueError("seq должен быть представлен 8 байтами (64 бита)") + + # Конвертируем байты в int (big-endian, unsigned) + seq = int.from_bytes(seq_bytes, byteorder="big") + + return chat_id, seq diff --git a/tests/test_message_url.py b/tests/test_message_url.py new file mode 100644 index 0000000..76ee2f9 --- /dev/null +++ b/tests/test_message_url.py @@ -0,0 +1,220 @@ +"""Тесты для свойства Message.url с объединённой логикой. + +Проверяются два формата URL: + +1. URL, полученный от API для постов в канале: + https://max.ru/{channel_name}/{seq_b64} +2. URL, сгенерированный для диалогов и групповых чатов: + https://max.ru/c/{chat_id}/{seq_b64} + +""" + +from maxapi.types.message import Message + + +class TestMessageUrlProperty: + """Тесты для свойства url в модели Message.""" + + def test_url_from_api_channel_post(self): + """URL из API для поста в канале — возвращается как есть.""" + api_url = "https://max.ru/news_channel/AZ2H-TzaAOc" + data = { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "text": "Channel post", + }, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + dumped = msg.model_dump() + assert dumped["url"] == api_url + assert "url_api" not in dumped + + def test_url_generated_for_dialog(self): + """Для диалога без url из API — ссылка генерируется из body.mid.""" + data = { + "recipient": { + "chat_id": -73455901853123, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "text": "Hello", + }, + } + msg = Message.model_validate(data) + + assert msg.url == "https://max.ru/c/-73455901853123/AZ2H-TzaAOc" + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + def test_url_generated_for_group_chat(self): + """Для группового чата без url из API — ссылка генерируется.""" + data = { + "recipient": { + "chat_id": -71955698945289, + "user_id": None, + "chat_type": "chat", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + "text": "Chat group message", + }, + } + msg = Message.model_validate(data) + + assert msg.url == "https://max.ru/c/-71955698945289/AZ113QDTS3o" + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + def test_url_none_when_no_body(self): + """Если нет body — url возвращает None.""" + data = { + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 1234567890, + } + msg = Message.model_validate(data) + + assert msg.url is None + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + def test_url_none_when_no_body_but_url_from_api(self): + """Если API прислал url, но нет body — возвращается url из API.""" + api_url = "https://max.ru/special_channel/AZ113QDTS3o" + data = { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 1234567890, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + assert msg.model_dump()["url"] == api_url + + def test_serialization_preserves_original_url(self): + """Сериализация сохраняет оригинальный url, а не сгенерированный.""" + api_url = "https://max.ru/channel/AZ2H-TzaAOc" + + # Случай 1: с url из API + msg_with_url = Message.model_validate( + { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, + } + ) + dumped = msg_with_url.model_dump() + assert dumped["url"] == api_url + + # Случай 2: без url из API + msg_no_url = Message.model_validate( + { + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, + } + ) + dumped = msg_no_url.model_dump() + assert dumped["url"] is None + + def test_url_property_priority(self): + """url_api имеет приоритет над генерацией из body.""" + api_url = "https://max.ru/priority_test/AZ2H-TzaAOc" + data = { + "url": api_url, + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + + def test_url_with_negative_chat_id(self): + """Генерация ссылки работает с отрицательными chat_id (каналы).""" + expected_url = "https://max.ru/c/-71955698945289/AZ113QDTS3o" + + data = { + "recipient": { + "chat_id": -71955698945289, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + }, + } + msg = Message.model_validate(data) + + assert msg.url == expected_url + + def test_model_dump_json_includes_url(self): + """model_dump(mode='json') включает url с правильным значением.""" + msg = Message.model_validate( + { + "url": "https://max.ru/test/AZ113QDTS3o", + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + }, + } + ) + + dumped_json = msg.model_dump(mode="json") + assert dumped_json["url"] == "https://max.ru/test/AZ113QDTS3o" diff --git a/tests/test_utils/test_message_link.py b/tests/test_utils/test_message_link.py new file mode 100644 index 0000000..3a0ba34 --- /dev/null +++ b/tests/test_utils/test_message_link.py @@ -0,0 +1,382 @@ +import base64 +import re + +import pytest +from maxapi.utils.message_link import ( + build_message_link, + chatid_seq_to_mid, + link_to_chatid_seq, + mid_to_chatid_seq, +) + +# ============================================================================= +# Реальные тестовые данные из продакшена +# ============================================================================= +REAL_TEST_CASES = [ + # Положительный chat_id + { + "chat_id": 191387420, + "mid": "mid.000000000b68571c019d5eac630d58ce", + "seq": 116353259870705870, + "link": None, # ссылка не предоставлена, сгенерируем в тесте + }, + { + "chat_id": 191387420, + "mid": "mid.000000000b68571c019d5eaa646c000f", + "seq": 116353251303751695, + "link": None, + }, + # Отрицательный chat_id с готовой ссылкой + { + "chat_id": -73455901853123, + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "link": "https://max.ru/c/-73455901853123/AZ2H-TzaAOc", + }, + { + "chat_id": -71955698945289, + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + "link": "https://max.ru/c/-71955698945289/AZ113QDTS3o", + }, +] + + +# ============================================================================= +# Тесты на реальных данных +# ============================================================================= +class TestRealData: + """Тесты на реальных примерах из продакшена""" + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_mid_to_chatid_seq_real(self, case): + """Декодирование mid -> (chat_id, seq)""" + chat_id, seq = mid_to_chatid_seq(case["mid"]) + assert chat_id == case["chat_id"] + assert seq == case["seq"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_chatid_seq_to_mid_real(self, case): + """Кодирование (chat_id, seq) -> mid""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + assert mid == case["mid"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_roundtrip_mid(self, case): + """Круговое преобразование: mid -> (chat_id, seq) -> mid""" + chat_id, seq = mid_to_chatid_seq(case["mid"]) + mid_restored = chatid_seq_to_mid(chat_id, seq) + assert mid_restored == case["mid"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_roundtrip_chatid_seq(self, case): + """Круговое преобразование: (chat_id, seq) -> mid -> (chat_id, seq)""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == case["chat_id"] + assert seq_restored == case["seq"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_build_message_link_real(self, case): + """Генерация ссылки из mid""" + link = build_message_link(case["mid"]) + assert link == case["link"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_link_to_chatid_seq_real(self, case): + """Парсинг ссылки -> (chat_id, seq)""" + chat_id, seq = link_to_chatid_seq(case["link"]) + assert chat_id == case["chat_id"] + assert seq == case["seq"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_roundtrip_link(self, case): + """Круговое преобразование: link -> (chat_id, seq) -> link""" + chat_id, seq = link_to_chatid_seq(case["link"]) + mid = chatid_seq_to_mid(chat_id, seq) + link_restored = build_message_link(mid) + assert link_restored == case["link"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_full_pipeline(self, case): + """Полный пайплайн: (chat_id, seq) -> mid -> link -> (chat_id, seq)""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + link = build_message_link(mid) + chat_id_parsed, seq_parsed = link_to_chatid_seq(link) + + assert chat_id_parsed == case["chat_id"] + assert seq_parsed == case["seq"] + + # Дополнительно: если была исходная ссылка, проверяем совпадение + if case["link"]: + assert link == case["link"] + + +# ============================================================================= +# Тесты на граничные значения +# ============================================================================= +class TestEdgeCases: + """Тесты на граничные значения 64-битных чисел""" + + def test_chat_id_min_signed(self): + """Минимальное значение signed int64: -2^63""" + chat_id = -(1 << 63) + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + def test_chat_id_max_signed(self): + """Максимальное значение signed int64: 2^63 - 1""" + chat_id = (1 << 63) - 1 + seq = (1 << 64) - 1 # max unsigned seq + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + def test_seq_zero(self): + """seq = 0""" + chat_id = 0 + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.00000000000000000000000000000000" + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == 0 + assert seq_restored == 0 + + def test_seq_max_unsigned(self): + """seq = 2^64 - 1 (максимальное unsigned 64-bit)""" + chat_id = 0 + seq = (1 << 64) - 1 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.0000000000000000ffffffffffffffff" + _, seq_restored = mid_to_chatid_seq(mid) + assert seq_restored == seq + + def test_negative_chat_id_hex_representation(self): + """Проверка two's complement для отрицательных chat_id""" + # -1 в two's complement 64-bit = 0xFFFFFFFFFFFFFFFF + chat_id = -1 + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.ffffffffffffffff0000000000000000" + chat_id_restored, _ = mid_to_chatid_seq(mid) + assert chat_id_restored == -1 + + +# ============================================================================= +# Тесты на ошибки валидации +# ============================================================================= +class TestValidationErrors: + """Тесты на корректную обработку невалидных входных данных""" + + # ------------------ mid_to_chatid_seq ------------------ + def test_mid_wrong_type(self): + with pytest.raises(TypeError, match="mid должен быть строкой"): + mid_to_chatid_seq(12345) # type: ignore + + def test_mid_wrong_values(self): + match = re.escape('mid должен быть в формате "mid." + 32 hex-символа') + + # missing prefix + with pytest.raises(ValueError, match=match): + mid_to_chatid_seq("000000000b68571c019d5eac630d58ce") + + # wrong length short + with pytest.raises(ValueError, match=match): + mid_to_chatid_seq("mid.000000000b68571c") + + # wrong length long + with pytest.raises(ValueError, match=match): + mid_to_chatid_seq("mid.000000000b68571c019d5eac630d58ce00") + + # invalid_hex_chars + with pytest.raises(ValueError, match=match): + # "g" не hex + mid_to_chatid_seq("mid.000000000b68571g019d5eac630d58ce") + + # ------------------ chatid_seq_to_mid ------------------ + def test_chatid_wrong_type(self): + with pytest.raises( + TypeError, match="chat_id должен быть целым числом" + ): + chatid_seq_to_mid("123", 100) # type: ignore + + def test_seq_wrong_type(self): + with pytest.raises(TypeError, match="seq должен быть целым числом"): + chatid_seq_to_mid(123, "100") # type: ignore + + def test_chat_id_out_of_range_low(self): + with pytest.raises(ValueError, match="chat_id выходит за пределы"): + chatid_seq_to_mid(-(1 << 63) - 1, 0) + + def test_chat_id_out_of_range_high(self): + with pytest.raises(ValueError, match="chat_id выходит за пределы"): + chatid_seq_to_mid(1 << 63, 0) + + def test_seq_negative(self): + with pytest.raises(ValueError, match="seq выходит за пределы"): + chatid_seq_to_mid(0, -1) + + def test_seq_out_of_range(self): + with pytest.raises(ValueError, match="seq выходит за пределы"): + chatid_seq_to_mid(0, 1 << 64) + + # ------------------ build_message_link ------------------ + def test_link_wrong_mid(self): + match = re.escape('mid должен быть в формате "mid." + 32 hex-символа') + + # wrong mid prefix + with pytest.raises(ValueError, match=match): + build_message_link("invalid.000000000b68571c019d5eac630d58ce") + + # wrong_mid_format + with pytest.raises(ValueError, match=match): + build_message_link("mid.000000000b68571g019d5eac630d58ce") + + # wrong_mid_length + with pytest.raises(ValueError, match=match): + build_message_link("mid.0000") + + # ------------------ link_to_chatid_seq ------------------ + def test_link_wrong_type(self): + with pytest.raises(TypeError, match="link должен быть строкой"): + link_to_chatid_seq(12345) # type: ignore + + def test_link_wrong_scheme(self): + with pytest.raises( + ValueError, match="Ссылка должна использовать https схему" + ): + link_to_chatid_seq("ftp://max.ru/c/123/ABC") + + def test_link_wrong_domain(self): + with pytest.raises( + ValueError, match=r"Ссылка должна указывать на домен max.ru" + ): + link_to_chatid_seq("https://example.com/c/123/ABC") + + def test_link_wrong_path_format(self): + with pytest.raises(ValueError, match="Неверный формат пути"): + link_to_chatid_seq("https://max.ru/chat/123/ABC") + + def test_link_wrong_path_parts_count(self): + with pytest.raises(ValueError, match="Неверный формат пути"): + link_to_chatid_seq("https://max.ru/c/123") # нет seq + + def test_link_chat_id_not_int(self): + with pytest.raises( + ValueError, match="chat_id в ссылке должен быть целым числом" + ): + link_to_chatid_seq("https://max.ru/c/abc/ABC") + + def test_link_invalid_base64_chars(self): + with pytest.raises( + ValueError, match="должен быть в url-safe base64 формате" + ): + link_to_chatid_seq("https://max.ru/c/123/ABC@#$") + + def test_link_base64_decode_error(self): + # Некорректный base64 (невозможно декодировать) + with pytest.raises(ValueError, match="Ошибка декодирования base64"): + link_to_chatid_seq("https://max.ru/c/123/A") + + def test_link_seq_wrong_byte_length(self): + # Base64, который декодируется не в 8 байт + # "AQ" -> 1 байт после декодирования + with pytest.raises( + ValueError, match="seq должен быть представлен 8 байтами" + ): + link_to_chatid_seq("https://max.ru/c/123/AQ") + + +# ============================================================================= +# Тесты на генерацию ссылок (build_message_link) +# ============================================================================= +class TestBuildMessageLink: + """Тесты функции build_message_link""" + + def test_link_format_positive_chat(self): + """Проверка формата ссылки для положительного chat_id""" + mid = "mid.000000000b68571c019d5eac630d58ce" + link = build_message_link(mid) + assert link.startswith("https://max.ru/c/") + assert "191387420" in link # chat_id в ссылке + + def test_link_format_negative_chat(self): + """Проверка формата ссылки для отрицательного chat_id""" + mid = "mid.ffffbd3137103a3d019d87f93cda00e7" + link = build_message_link(mid) + assert link.startswith("https://max.ru/c/-") + assert "-73455901853123" in link + + def test_link_seq_base64_urlsafe_no_padding(self): + """seq в ссылке должен быть url-safe base64 без паддинга""" + mid = "mid.00000000000000000000000000000001" # seq = 1 + link = build_message_link(mid) + seq_part = link.split("/")[-1] + assert "=" not in seq_part # нет паддинга + assert all( + c + in ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789-_" + ) + for c in seq_part + ) + + def test_link_seq_decoding_consistency(self): + """Декодирование seq из ссылки должно давать исходное значение""" + + def _seq_to_b64(seq: int) -> str: + """Вспомогательная функция: seq → URL-safe base64 без паддинга.""" + seq_bytes = seq.to_bytes(8, "big") + return base64.urlsafe_b64encode(seq_bytes).decode().rstrip("=") + + test_cases = [ + 0, # Минимум (все биты 0) + 1, # Единица (проверка младших битов) + 255, # Граница 1 байта (0xFF) + 2**64 - 1, # Максимум unsigned int64 (все биты 1) + 116353259870705870, # Реальное значение из продакшена + ] + for seq in test_cases: + expected_b64 = _seq_to_b64(seq) + mid = chatid_seq_to_mid(12345, seq) + link = build_message_link(mid) + seq_from_link = link.split("/")[-1] + # Проверяем, что декодирование даёт исходный seq + assert seq_from_link == expected_b64 + padding = (4 - len(seq_from_link) % 4) % 4 + decoded = base64.urlsafe_b64decode(seq_from_link + "=" * padding) + assert int.from_bytes(decoded, "big") == seq + + +# ============================================================================= +# Параметризованные тесты для покрытия различных сценариев +# ============================================================================= +@pytest.mark.parametrize( + ("chat_id", "seq"), + [ + (0, 0), + (1, 1), + (-1, 1), + (999999999, 999999999), + (-999999999, 999999999), + (2**62, 2**63), # большие значения в пределах диапазона + (-(2**62), 2**63 - 1), + ], +) +def test_parametrized_roundtrip(chat_id: int, seq: int): + """Параметризованный тест кругового преобразования""" + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + link = build_message_link(mid) + chat_id_from_link, seq_from_link = link_to_chatid_seq(link) + assert chat_id_from_link == chat_id + assert seq_from_link == seq