diff --git a/Dockerfile b/Dockerfile index 75aa4e5..d49ece3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py apinex_client.py user_store.py help_message.py VERSION ./ +COPY bot.py apinex_client.py user_store.py help_message.py payments.py VERSION ./ CMD ["python", "bot.py"] diff --git a/IDEA.MD b/IDEA.MD index 6b4780f..305046b 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -44,7 +44,7 @@ Telegram-бот, который по запросу пользователя г - Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. - SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`. - Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории. -- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+24`). +- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+25`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -72,6 +72,7 @@ Telegram-бот, который по запросу пользователя г - [x] Development deployment/version notice and user-visible error details. - [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг - [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки +- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) @@ -81,7 +82,7 @@ Telegram-бот, который по запросу пользователя г **Выполнено (Готово):** MVP генерации и STT · классификатор намерений и ToV · SQLite-настройки и reply-правки · деплой в Coolify + volume · API-логи · `/clear` · `/idea` · онбординг · подписка · документация. **В планах (Backlog):** -1. **Реальная оплата звёздами** (Telegram Payments: `sendInvoice`/`createInvoiceLink`, `pre_checkout_query`, `successful_payment`) — сейчас имитация. +1. **Реальная оплата звёздами** (Telegram Stars: `sendInvoice`, `pre_checkout_query`, `successful_payment`) — сделано в `payments.py`; осталось проверить возврат (`refundStarPayment`) и автопродление. 2. Автопродление подписки и уведомления об окончании. 3. Админ-панель: статистика пользователей, подписок, расходов APInex. 4. Лимиты на генерации (защита расходов на API). diff --git a/README.md b/README.md index 10acbb2..e22a3c6 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,11 @@ Telegram-бот для генерации постов для соцсетей. ``` bot.py — роутинг, команды, обработка сообщений (aiogram 3.x) +payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback -VERSION — версия релиза (сейчас 0.1.0+24) +VERSION — версия релиза (сейчас 0.1.0+25) ``` ### Основные потоки @@ -100,7 +101,9 @@ update_tone_from_image → _handle_image_message | `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует | | `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) | | `balance_command` | `/balance` — статус подписки | -| `subscribe_command` | `/subscribe` — активация подписки (оплата 100 звёзд) | +| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) | +| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет | +| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления | | `clear_command` | `/clear` — сброс данных + обнуление подписки | | `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text | | `help_navigation` | Callback `help:
` → `edit_message_text(rich_message=...)` — переключение разделов | @@ -145,10 +148,10 @@ SUBSCRIPTION_PRICE_STARS = 100 1. `/start` → `ensure_subscription` → триал 3 дня, `is_active = 1`. 2. Генерация доступна, пока `_days_until > 0`. 3. Триал кончился → `_check_subscription` блокирует, предлагает `/subscribe`. -4. `/subscribe` → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`. +4. `/subscribe` → `sendInvoice` (XTR, 100 звёзд) → `pre_checkout_query` (проверка суммы/валюты/payload) → `successful_payment` → `activate_subscription(months=1)` → +30 дней. 5. `/clear` → `deactivate_subscription` → `is_active = 0`; нужен `/subscribe`. -**Важно про оплату:** приём звёзд — **имитация**. Бот пишет «пришли 100 звёзд» и сразу активирует подписку, не проводя реальный платёж. Для настоящих платежей нужен отдельный invoicing-флоу (Telegram Payments / `sendInvoice` для Stars), что в этой версии не реализовано. +**Оплата реальная:** `payments.py` проводит настоящий платёж Telegram Stars. Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. Платится валюта `XTR`, `provider_token` пустой (цифровой товар). **Часовые пояса:** SQLite `CURRENT_TIMESTAMP` возвращает UTC без tz. Код аккуратно приводит все значения к naive UTC перед сравнением, чтобы не было сдвигов. @@ -170,7 +173,7 @@ SUBSCRIPTION_PRICE_STARS = 100 | `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ | | `/help` | Меню справки: полноширинные кнопки разделов, переход в любой раздел + plain fallback | ✅ | | `/balance` | Статус подписки / триала | ✅ | -| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ | +| `/subscribe` | Счёт на оплату: 100 звёзд (XTR) за 30 дней; активация — после оплаты | ✅ | | `/idea` | Одна идея для поста | ❌ (нужна подписка) | | `/clear` | Полный сброс данных + обнуление подписки | ✅ | @@ -195,4 +198,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index 769a760..f17aa32 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+24 +0.1.0+25 diff --git a/bot.py b/bot.py index 54677d5..c8ba44e 100644 --- a/bot.py +++ b/bot.py @@ -14,10 +14,12 @@ from aiogram.client.telegram import TEST from aiogram.filters import CommandStart from aiogram.types import CallbackQuery, InputRichMessage, Message +import payments import user_store from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain +from payments import send_subscription_invoice from user_store import ( add_post_samples, clear_user_data, @@ -36,7 +38,6 @@ from user_store import ( SUBSCRIPTION_PRICE_STARS, ensure_subscription, subscription_row, - activate_subscription, ) logging.basicConfig(level=logging.INFO) @@ -210,16 +211,22 @@ async def subscribe_command(message: Message) -> None: await message.answer("Не удалось определить пользователя.") return await asyncio.to_thread(ensure_subscription, message.from_user.id) + try: + await send_subscription_invoice( + message.bot, message.chat.id, message.message_thread_id + ) + except Exception as exc: + logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180]) + if DEPLOY_ENV == "development": + await report_error(message, "отправка счёта на оплату", exc) + await message.answer( + "Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту " + "или напиши поддержке." + ) + return await message.answer( - f"Активирую подписку на 30 дней. " - f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n" - f"Жду оплаты..." - ) - days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1) - await asyncio.sleep(0.6) - await message.answer( - f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n" - f"Теперь доступны генерация постов, идей и голосовые." + f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" + f"Подписка активируется сразу после оплаты — отдельного подтверждения не нужно." ) @@ -517,6 +524,7 @@ async def handle_text(message: Message) -> None: async def main() -> None: init_db() + payments.init_payments_table() token = os.environ.get("BOT_TOKEN", "").strip() if not token: raise RuntimeError("BOT_TOKEN is required") @@ -526,6 +534,7 @@ async def main() -> None: bot = Bot(token=token, session=session) await notify_development_deploy(bot) dp = Dispatcher() + dp.include_router(payments.router) dp.include_router(router) try: diff --git a/payments.py b/payments.py new file mode 100644 index 0000000..db6d4d5 --- /dev/null +++ b/payments.py @@ -0,0 +1,159 @@ +"""Реальная оплата Telegram Stars (XTR) за подписку. + +Цикл: sendInvoice -> pre_checkout_query -> answerPreCheckoutQuery -> +successful_payment -> activate_subscription. +`telegram_payment_charge_id` каждого платежа пишется в таблицу payments, +поэтому один платёж не может активировать подписку дважды, а по нему потом +можно сделать возврат через refundStarPayment. +""" +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +from contextlib import closing + +from aiogram import F, Router +from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment + +from user_store import ( + SUBSCRIPTION_PRICE_STARS, + activate_subscription, + connect, + ensure_subscription, +) + +logger = logging.getLogger(__name__) +router = Router(name="payments") + +STARS_CURRENCY = "XTR" +INVOICE_PREFIX = "sub30:" + + +def _db_path(db_path: str | None = None) -> str | None: + return db_path if db_path is not None else os.getenv("BOT_DB_PATH") + + +def init_payments_table(db_path: str | None = None) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS payments ( + telegram_payment_charge_id TEXT PRIMARY KEY, + telegram_user_id INTEGER NOT NULL, + invoice_payload TEXT NOT NULL, + currency TEXT NOT NULL, + total_amount INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(telegram_user_id)" + ) + + +def payment_recorded(charge_id: str, db_path: str | None = None) -> bool: + with closing(connect(db_path)) as connection: + row = connection.execute( + "SELECT 1 FROM payments WHERE telegram_payment_charge_id = ?", (charge_id,) + ).fetchone() + return row is not None + + +def save_payment( + charge_id: str, + user_id: int, + invoice_payload: str, + currency: str, + total_amount: int, + db_path: str | None = None, +) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + """ + INSERT OR IGNORE INTO payments + (telegram_payment_charge_id, telegram_user_id, invoice_payload, currency, total_amount) + VALUES (?, ?, ?, ?, ?) + """, + (charge_id, user_id, invoice_payload, currency, total_amount), + ) + + +async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = None) -> None: + """Отправляет инвойс на 30 дней подписки за Stars.""" + payload = f"{INVOICE_PREFIX}{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}" + kwargs: dict[str, object] = { + "chat_id": chat_id, + "title": "Подписка на 30 дней", + "description": "Доступ к генерации постов, идей и голосовым на 30 дней.", + "payload": payload, + "currency": STARS_CURRENCY, + "prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)], + "provider_token": "", + } + if thread_id is not None: + kwargs["message_thread_id"] = thread_id + await bot.send_invoice(**kwargs) + + +@router.pre_checkout_query() +async def handle_pre_checkout(query: PreCheckoutQuery) -> None: + """Подтверждаем только свой инвойс, в правильной валюте и по правильной цене.""" + if query.currency != STARS_CURRENCY or query.total_amount != SUBSCRIPTION_PRICE_STARS: + await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.") + return + if not query.invoice_payload.startswith(INVOICE_PREFIX): + await query.answer(ok=False, error_message="Этот счёт не принадлежит подписке.") + return + await query.answer(ok=True) + + +@router.message(F.successful_payment) +async def handle_successful_payment(message: Message) -> None: + payment = message.successful_payment + if payment is None or message.from_user is None: + return + + charge_id = payment.telegram_payment_charge_id + if payment.currency != STARS_CURRENCY or payment.total_amount != SUBSCRIPTION_PRICE_STARS: + await _reject(message, charge_id, "неверная сумма или валюта") + return + if not payment.invoice_payload.startswith(INVOICE_PREFIX): + await _reject(message, charge_id, "неизвестный invoice_payload") + return + + db_path = _db_path() + if await asyncio.to_thread(payment_recorded, charge_id, db_path): + await message.answer("Этот платёж уже учтён — подписка не продлена повторно.") + return + + await asyncio.to_thread(ensure_subscription, message.from_user.id) + days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1) + await asyncio.to_thread( + save_payment, + charge_id, + message.from_user.id, + payment.invoice_payload, + payment.currency, + payment.total_amount, + db_path, + ) + await message.answer( + f"Оплата получена — подписка активирована на {days} дн.\n\n" + f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, " + f"идей и голосовые. Команда /balance покажет статус." + ) + + +async def _reject(message: Message, charge_id: str, reason: str) -> None: + logger.error( + "Rejected payment charge=%s user=%s reason=%s", + charge_id, + message.from_user.id if message.from_user else None, + reason, + ) + await message.answer( + "Платёж получен, но активировать подписку не получилось. Напиши, пожалуйста, поддержке." + ) diff --git a/tests/test_payments.py b/tests/test_payments.py new file mode 100644 index 0000000..34dc7eb --- /dev/null +++ b/tests/test_payments.py @@ -0,0 +1,220 @@ +import os +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +from types import SimpleNamespace + +from aiogram.types import LabeledPrice, SuccessfulPayment + +import bot +import payments +import user_store + + +class PaymentStoreTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) + self.tmp.close() + self.db = self.tmp.name + user_store.init_db(self.db) + payments.init_payments_table(self.db) + + def tearDown(self): + os.unlink(self.db) + + def test_payment_is_recorded_once(self): + payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db) + self.assertTrue(payments.payment_recorded("charge1", self.db)) + self.assertFalse(payments.payment_recorded("charge2", self.db)) + + def test_save_payment_is_idempotent(self): + payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db) + payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db) + with payments.closing(payments.connect(self.db)) as connection: + rows = connection.execute("SELECT COUNT(*) FROM payments").fetchone() + self.assertEqual(rows[0], 1) + + +class InvoiceTests(unittest.IsolatedAsyncioTestCase): + async def test_invoice_uses_stars_and_correct_price(self): + fake_bot = AsyncMock() + await payments.send_subscription_invoice(fake_bot, 555) + kwargs = fake_bot.send_invoice.await_args.kwargs + self.assertEqual(kwargs["currency"], "XTR") + self.assertEqual(kwargs["provider_token"], "") + self.assertEqual(kwargs["chat_id"], 555) + self.assertTrue(kwargs["payload"].startswith(payments.INVOICE_PREFIX)) + prices = kwargs["prices"] + self.assertIsInstance(prices[0], LabeledPrice) + self.assertEqual(prices[0].amount, user_store.SUBSCRIPTION_PRICE_STARS) + + async def test_invoice_includes_thread_when_given(self): + fake_bot = AsyncMock() + await payments.send_subscription_invoice(fake_bot, 555, thread_id=77) + self.assertEqual(fake_bot.send_invoice.await_args.kwargs["message_thread_id"], 77) + + async def test_invoice_omits_thread_when_absent(self): + fake_bot = AsyncMock() + await payments.send_subscription_invoice(fake_bot, 555) + self.assertNotIn("message_thread_id", fake_bot.send_invoice.await_args.kwargs) + + +class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): + def _query(self, currency="XTR", amount=100, payload="sub30:1"): + query = AsyncMock() + query.currency = currency + query.total_amount = amount + query.invoice_payload = payload + return query + + async def test_accepts_valid_subscription_invoice(self): + query = self._query() + await payments.handle_pre_checkout(query) + query.answer.assert_awaited_once_with(ok=True) + + async def test_rejects_wrong_amount(self): + query = self._query(amount=1) + await payments.handle_pre_checkout(query) + self.assertFalse(query.answer.await_args.kwargs["ok"]) + + async def test_rejects_wrong_currency(self): + query = self._query(currency="USD") + await payments.handle_pre_checkout(query) + self.assertFalse(query.answer.await_args.kwargs["ok"]) + + async def test_rejects_foreign_payload(self): + query = self._query(payload="someone-elses-invoice") + await payments.handle_pre_checkout(query) + self.assertFalse(query.answer.await_args.kwargs["ok"]) + + +def _direct_thread(*args, **kwargs): + """Вызывает синхронную функцию прямо, без пула потоков.""" + func = args[0] + return func(*args[1:], **kwargs) + + +def _direct_thread_mock(): + return AsyncMock(side_effect=_direct_thread) + +class _StubMessage: + """Минимальный Message: нам нужны только successful_payment, from_user и answer.""" + + def __init__(self, payment=None, user_id=42, chat_id=555): + self.successful_payment = payment + self.from_user = SimpleNamespace(id=user_id) + self.chat = SimpleNamespace(id=chat_id) + self.answers: list[str] = [] + + async def answer(self, text: str, **kwargs): + self.answers.append(text) + return self + + +def _paid_message(charge_id="charge1", amount=100, currency="XTR", payload="sub30:1"): + payment = SuccessfulPayment( + currency=currency, + total_amount=amount, + invoice_payload=payload, + telegram_payment_charge_id=charge_id, + provider_payment_charge_id="", + ) + return _StubMessage(payment=payment) + + +def _db() -> str: + handle = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) + handle.close() + user_store.init_db(handle.name) + payments.init_payments_table(handle.name) + return handle.name + + +class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): + def tearDown(self): + if getattr(self, "db", None): + os.unlink(self.db) + + async def test_payment_activates_subscription(self): + message = _paid_message() + self.db = _db() + user_store.ensure_subscription(42, self.db) + with patch.dict(os.environ, {"BOT_DB_PATH": self.db}): + await payments.handle_successful_payment(message) + self.assertEqual(len(message.answers), 1) + self.assertIn("активирована", message.answers[0]) + self.assertTrue(payments.payment_recorded("charge1", self.db)) + row = user_store.subscription_row(42, self.db) + self.assertIsNotNone(row["last_paid_at"]) + + async def test_duplicate_payment_does_not_extend_twice(self): + message = _paid_message(charge_id="charge-dup") + with ( + patch.object(payments, "payment_recorded", new=AsyncMock(return_value=True)), + patch.object(payments, "save_payment", new=AsyncMock()) as save, + patch.object(payments.asyncio, "to_thread", new=_direct_thread_mock()), + ): + await payments.handle_successful_payment(message) + save.assert_not_awaited() + self.assertIn("уже учтён", message.answers[0]) + + async def test_wrong_amount_is_rejected_without_activation(self): + message = _paid_message(amount=5) + with ( + patch.object(payments, "save_payment", new=AsyncMock()) as save, + patch.object(payments, "activate_subscription") as activate, + ): + await payments.handle_successful_payment(message) + activate.assert_not_called() + save.assert_not_awaited() + self.assertIn("поддержке", message.answers[0]) + + async def test_wrong_currency_is_rejected(self): + message = _paid_message(currency="USD") + with patch.object(payments, "activate_subscription") as activate: + await payments.handle_successful_payment(message) + activate.assert_not_called() + + async def test_foreign_payload_is_rejected_without_activation(self): + message = _paid_message(payload="other") + with patch.object(payments, "activate_subscription") as activate: + await payments.handle_successful_payment(message) + activate.assert_not_called() + + +class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): + def _message(self): + message = _StubMessage() + message.bot = AsyncMock() + message.message_thread_id = None + return message + + async def test_subscribe_sends_invoice_and_never_activates_directly(self): + from pathlib import Path + + source = Path(bot.__file__).read_text(encoding="utf-8") + body = source.split("async def subscribe_command")[1].split("\nasync def ")[0] + self.assertNotIn("activate_subscription", body) + + message = self._message() + with ( + patch.object(bot.asyncio, "to_thread", new=AsyncMock()), + patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice, + ): + await bot.subscribe_command(message) + invoice.assert_awaited_once_with(message.bot, 555, None) + self.assertIn("Счёт", message.answers[0]) + + async def test_subscribe_reports_failure_when_invoice_fails(self): + message = self._message() + with ( + patch.object(bot.asyncio, "to_thread", new=AsyncMock()), + patch.object(bot, "send_subscription_invoice", new=AsyncMock(side_effect=RuntimeError("boom"))), + ): + await bot.subscribe_command(message) + self.assertIn("поддержке", message.answers[0]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 86915fb..31281c3 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -523,10 +523,13 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(sub["is_active"], 1) self.assertGreater(user_store._days_until(sub), 0) - async def test_subscribe_command_activates_30_days(self): + async def test_subscribe_command_sends_invoice_without_activating(self): user_store.ensure_subscription(102, self.db_path) message = SimpleNamespace( from_user=SimpleNamespace(id=102), + chat=SimpleNamespace(id=555), + message_thread_id=None, + bot=AsyncMock(), answer=AsyncMock(), ) @@ -534,16 +537,16 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): return function(*args, **kwargs) with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): - with patch.object(bot, "notify_development_deploy", new=AsyncMock()): + with patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice: await bot.subscribe_command(message) - msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("Активирую подписку на 30 дней" in m for m in msg_calls)) - self.assertTrue(any("Подписка активирована на 30 дн." in m for m in msg_calls)) + invoice.assert_awaited_once() + # Подписка не активируется до реальной оплаты. sub = user_store.subscription_row(102, self.db_path) - self.assertIsNotNone(sub["last_paid_at"]) - self.assertEqual(sub["is_active"], 1) - self.assertTrue(user_store._days_until(sub) >= 29) + self.assertIsNone(sub["last_paid_at"]) + msg_calls = [c.args[0] for c in message.answer.call_args_list] + self.assertTrue(any("Счёт" in m for m in msg_calls)) + async def test_balance_command_shows_subscription_info(self): user_store.ensure_subscription(103, self.db_path) @@ -601,4 +604,4 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file