test: add zero-star invoice probe

This commit is contained in:
admin committed 2026-10-21 12:56:50 +02:00
1 parent 13230e3fe2
commit 3819230b3e
6 files changed
+66 -28

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.1.0+67 0.1.0+68
+37 -15
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import datetime
import html import html
import io import io
import logging import logging
@@ -24,6 +25,7 @@ from aiogram.types import (
InlineKeyboardButton, InlineKeyboardButton,
InlineKeyboardMarkup, InlineKeyboardMarkup,
InputRichMessage, InputRichMessage,
LabeledPrice,
Message, Message,
ReplyKeyboardMarkup, ReplyKeyboardMarkup,
) )
@@ -174,10 +176,14 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
await _close_support_case(message) await _close_support_case(message)
await state.clear() await state.clear()
await state.set_state(MODE_STATES[mode]) await state.set_state(MODE_STATES[mode])
subscription = None if mode == "subscription":
if mode == "subscription" and message.from_user is not None: if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id) await asyncio.to_thread(ensure_subscription, message.from_user.id)
subscription = await asyncio.to_thread(subscription_row, message.from_user.id) subscription = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, subscription)
return
title, description = MODE_COPY[mode] title, description = MODE_COPY[mode]
if mode == "help": if mode == "help":
await send_rich_message( await send_rich_message(
@@ -187,18 +193,9 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
reply_markup=help_inline_keyboard(), reply_markup=help_inline_keyboard(),
) )
await message.answer( await message.answer(
title if mode == "subscription" else f"{title}.\n{description}", f"{title}.\n{description}",
reply_markup=mode_keyboard( reply_markup=mode_keyboard(mode),
mode,
subscription_purchased=bool(subscription and subscription["last_paid_at"]),
),
) )
if mode == "subscription" and message.from_user is not None:
await _show_subscription_info(message, subscription)
elif mode == "subscription":
await message.answer(
"Не удалось определить пользователя.", reply_markup=mode_keyboard("subscription")
)
async def show_main_menu(message: Message, state: FSMContext) -> None: async def show_main_menu(message: Message, state: FSMContext) -> None:
@@ -425,7 +422,7 @@ async def confirm_development_payment(callback: CallbackQuery) -> None:
if callback.from_user is None or callback.from_user.id != user_id: if callback.from_user is None or callback.from_user.id != user_id:
await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True)
return return
if days not in user_store.SUBSCRIPTION_PLANS: if days <= 0:
await callback.answer("Неизвестный срок подписки.", show_alert=True) await callback.answer("Неизвестный срок подписки.", show_alert=True)
return return
await asyncio.to_thread(ensure_subscription, user_id) await asyncio.to_thread(ensure_subscription, user_id)
@@ -495,6 +492,26 @@ async def subscribe_command(message: Message, state: FSMContext) -> None:
await _send_subscription_invoice(message, message.from_user.id) await _send_subscription_invoice(message, message.from_user.id)
async def _send_zero_star_invoice(message: Message, user_id: int) -> None:
"""Sends a zero-Star invoice only as an API behavior probe; never grants access."""
try:
await message.bot.send_invoice(
chat_id=message.chat.id,
title="Тестовый счёт на 0 ⭐",
description="Проверка поддержки нулевой цены Telegram Stars",
payload=f"sub:0:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}",
currency="XTR",
prices=[LabeledPrice(label="Тест", amount=0)],
provider_token="",
message_thread_id=message.message_thread_id,
)
except Exception as exc:
logger.info("Zero-Star invoice rejected by Telegram (%s)", type(exc).__name__)
await message.answer("Telegram отклонил тестовый счёт на 0 ⭐.")
return
await message.answer("Telegram принял тестовый счёт на 0 ⭐. Доступ он не выдаёт.")
async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None: async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None:
await asyncio.to_thread(ensure_subscription, user_id) await asyncio.to_thread(ensure_subscription, user_id)
try: try:
@@ -520,11 +537,13 @@ async def _send_subscription_invoice(message: Message, user_id: int, days: int =
) )
@router.callback_query(F.data.startswith("subscription:plan:"))
async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None: async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None:
if await current_mode(state) != "subscription" or not isinstance(callback.message, Message): if await current_mode(state) != "subscription" or not isinstance(callback.message, Message):
await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True) await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True)
return return
if callback.from_user is None:
await callback.answer("Не удалось определить пользователя.", show_alert=True)
return
try: try:
days = int((callback.data or "").rsplit(":", 1)[1]) days = int((callback.data or "").rsplit(":", 1)[1])
except ValueError: except ValueError:
@@ -534,6 +553,9 @@ async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer("Такой тариф недоступен.", show_alert=True) await callback.answer("Такой тариф недоступен.", show_alert=True)
return return
await callback.answer() await callback.answer()
if days == 0:
await _send_zero_star_invoice(callback.message, callback.from_user.id)
return
try: try:
await callback.message.delete() await callback.message.delete()
except Exception as exc: except Exception as exc:
+1
View File
@@ -90,6 +90,7 @@ def mode_keyboard(
def subscription_plans_keyboard() -> InlineKeyboardMarkup: def subscription_plans_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[ return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="0 дней — 0 ⭐ (тест)", callback_data="subscription:plan:0")],
[InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")], [InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")],
[InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")], [InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")],
[InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")], [InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")],
+4 -12
View File
@@ -238,14 +238,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
): ):
await bot.handle_text(message, state) await bot.handle_text(message, state)
self.assertEqual(await state.get_state(), bot.BotMode.subscription.state) self.assertEqual(await state.get_state(), bot.BotMode.subscription.state)
self.assertEqual(len(message.answers), 2) self.assertIn("Подписка закончилась", message.answers[0][0])
initial_keyboard = message.answers[0][1]["reply_markup"]
self.assertEqual(
[button.text for row in initial_keyboard.keyboard for button in row],
[keyboards.BUY_SUBSCRIPTION_BUTTON, keyboards.MAIN_MENU_BUTTON],
)
self.assertIn("Подписка активна", message.answers[1][0])
self.assertIn("Закончится через", message.answers[1][0])
async def test_subscription_status_uses_renew_button_after_previous_payment(self): async def test_subscription_status_uses_renew_button_after_previous_payment(self):
message = _StubMessage() message = _StubMessage()
@@ -253,9 +246,8 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
with patch.object(bot.user_store, "_days_until", return_value=2): with patch.object(bot.user_store, "_days_until", return_value=2):
await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
markup = message.answers[0][1]["reply_markup"] markup = message.answers[0][1]["reply_markup"]
self.assertFalse(hasattr(markup, "inline_keyboard"))
self.assertIn("Подписка активна", message.answers[0][0]) self.assertIn("Подписка активна", message.answers[0][0])
self.assertIn("Закончится через", message.answers[0][0]) self.assertIsNotNone(markup)
async def test_subscription_action_button_shows_inline_plan_choices(self): async def test_subscription_action_button_shows_inline_plan_choices(self):
message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON) message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON)
@@ -264,7 +256,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(message.answers), 1) self.assertEqual(len(message.answers), 1)
self.assertEqual(message.answers[0][0], "Выберите срок подписки") self.assertEqual(message.answers[0][0], "Выберите срок подписки")
labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row] labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row]
self.assertEqual(labels, ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) self.assertEqual(labels, ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
async def test_subscription_action_button_is_rejected_outside_subscription_mode(self): async def test_subscription_action_button_is_rejected_outside_subscription_mode(self):
message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON)
@@ -286,7 +278,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(message_bot.mock_calls), 2) self.assertEqual(len(message_bot.mock_calls), 2)
sent = message_bot.mock_calls[-1].args[0] sent = message_bot.mock_calls[-1].args[0]
self.assertEqual(sent.text, "Выберите срок подписки") self.assertEqual(sent.text, "Выберите срок подписки")
self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
async def test_idea_command_generates_only_in_generation_mode(self): async def test_idea_command_generates_only_in_generation_mode(self):
message = _StubMessage("/idea") message = _StubMessage("/idea")
+22
View File
@@ -230,6 +230,28 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)") self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)")
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10") self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10")
async def test_zero_star_plan_is_present_and_does_not_grant_access(self):
from pathlib import Path
self.assertEqual(user_store.SUBSCRIPTION_PLANS[0], 0)
source = Path(bot.__file__).read_text(encoding="utf-8")
body = source.split("async def _send_zero_star_invoice")[1].split("\nasync def _send_subscription_invoice")[0]
self.assertIn("amount=0", body)
self.assertNotIn("activate_subscription", body)
self.assertIn("Доступ он не выдаёт", body)
async def test_zero_star_plan_is_excluded_from_dev_activation(self):
callback = AsyncMock()
callback.data = "dev_paid:42:0"
callback.from_user = SimpleNamespace(id=42)
with (
patch.object(bot, "DEPLOY_ENV", "development"),
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
):
await bot.confirm_development_payment(callback)
to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with("Неизвестный срок подписки.", show_alert=True)
async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self): async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self):
callback = AsyncMock() callback = AsyncMock()
callback.data = "dev_paid:42:10" callback.data = "dev_paid:42:10"
+1
View File
@@ -142,6 +142,7 @@ TRIAL_DAYS = 3
SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием. SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
SUBSCRIPTION_PRICE_STARS = 450 SUBSCRIPTION_PRICE_STARS = 450
SUBSCRIPTION_PLANS = { SUBSCRIPTION_PLANS = {
0: 0,
1: 15, 1: 15,
10: 150, 10: 150,
30: 450, 30: 450,