diff --git a/bot/handlers/regions.py b/bot/handlers/regions.py index 6fd4f43..b87dda8 100644 --- a/bot/handlers/regions.py +++ b/bot/handlers/regions.py @@ -17,6 +17,7 @@ get_user_regions, remove_user_region, ) +from services.subscription import sync_subscriptions_for_new_region from services.user import get_or_create_user _MAX_REGION_RESULTS = 20 @@ -125,7 +126,7 @@ async def on_region_add( await session.commit() region = await get_or_create_region(session, locale, country["name"]) - added = await add_user_region(session, user.id, region.id) + added = await add_user_region(session, user, region.id) await state.clear() @@ -134,6 +135,7 @@ async def on_region_add( f"✓ {country['name']} added to your tracked regions.\n\n" "View your regions: /my_regions" ) + await sync_subscriptions_for_new_region(session, user, region) else: await callback.answer("This region is already in your list.", show_alert=True) @@ -149,7 +151,7 @@ async def on_region_remove( ) await session.commit() - await remove_user_region(session, user.id, region_id) + await remove_user_region(session, user, region_id) regions = await get_user_regions(session, user.id) if not regions: diff --git a/bot/handlers/search.py b/bot/handlers/search.py index 24e2f8d..896f91d 100644 --- a/bot/handlers/search.py +++ b/bot/handlers/search.py @@ -7,27 +7,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.formatters import format_game_card, format_game_list -from bot.keyboards.inline import search_results_keyboard +from bot.keyboards.inline import game_card_keyboard, search_results_keyboard from bot.states.subscription import SearchForm from services.currency import get_rates -from services.ps_store import GameInfo, RegionPrice, get_game_info, normalize_title, search_games +from services.ps_store import GameInfo, RegionPrice, best_ps_id, get_game_info, search_games from services.region import get_user_regions +from services.subscription import is_subscribed from services.user import get_or_create_user -# PS Store product ID prefixes by region group -_COUNTRY_TO_PS_PREFIX: dict[str, str] = { - "us": "UP", "ca": "UP", "mx": "UP", "br": "UP", "ar": "UP", "cl": "UP", "co": "UP", - "jp": "JP", - "kr": "KP", -} - - -def _best_ps_id(region_code: str, ps_ids: dict[str, str]) -> str | None: - country = region_code.split("-")[-1].lower() - preferred = _COUNTRY_TO_PS_PREFIX.get(country, "EP") - return next((pid for pid in ps_ids.values() if pid.startswith(preferred)), None) - - _MAX_SEARCH_RESULTS = 15 router = Router() @@ -57,10 +44,8 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession, for region, region_games in zip(user_regions, results): for game, price in region_games: - key = normalize_title(game.title) - # Don't overwrite a paid price with a free/unavailable one - if price.price is not None or region.code not in by_title.get(key, {}): - by_title.setdefault(key, {})[region.code] = price + key = game.normalized_title + by_title.setdefault(key, {})[region.code] = price # Prefer ASCII title so localized prefixes ("Набір", "세트" etc.) don't win if key not in rep_game or game.title.isascii(): rep_game[key] = game @@ -78,7 +63,7 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession, continue for region in user_regions: if region.code not in found: - best = _best_ps_id(region.code, ps_ids_by_title[title_key]) + best = best_ps_id(region.code, ps_ids_by_title[title_key]) if best: fallback_tasks.append((title_key, region, best)) @@ -90,11 +75,7 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession, for (title_key, region, _), result in zip(fallback_tasks, fallback_results): if result is not None: _, region_price = result - if region_price is not None: - by_title[title_key][region.code] = region_price - - # Exclude games with no purchasable price in any of the user's regions (free games, demos, removed titles). - all_keys = [k for k in all_keys if any(rp.price is not None for rp in by_title.get(k, {}).values())] + by_title[title_key][region.code] = region_price all_games = [rep_game[k] for k in all_keys] games = all_games[:_MAX_SEARCH_RESULTS] @@ -148,7 +129,7 @@ async def on_search_query( @router.callback_query(F.data.startswith("game_select:")) -async def on_game_select(callback: CallbackQuery, state: FSMContext) -> None: +async def on_game_select(callback: CallbackQuery, state: FSMContext, session: AsyncSession) -> None: await callback.answer() data = await state.get_data() @@ -187,8 +168,10 @@ async def on_game_select(callback: CallbackQuery, state: FSMContext) -> None: rates, footer="Want to track prices in more regions?\nAdd a new one: /add_region", ) + subscribed = await is_subscribed(session, callback.from_user.id, game.normalized_title) + keyboard = game_card_keyboard(index, is_subscribed=subscribed) if game.cover_url: - await callback.message.answer_photo(photo=game.cover_url, caption=caption) + await callback.message.answer_photo(photo=game.cover_url, caption=caption, reply_markup=keyboard) else: - await callback.message.answer(caption) + await callback.message.answer(caption, reply_markup=keyboard) diff --git a/bot/handlers/subscriptions.py b/bot/handlers/subscriptions.py index f1bbec1..5e232a1 100644 --- a/bot/handlers/subscriptions.py +++ b/bot/handlers/subscriptions.py @@ -1,14 +1,72 @@ -from aiogram import Router +from aiogram import F, Router from aiogram.filters import Command -from aiogram.types import Message +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message from sqlalchemy.ext.asyncio import AsyncSession +from bot.states.subscription import SearchForm +from services.ps_store import GameInfo, RegionPrice +from services.subscription import subscribe_to_game, unsubscribe_from_game +from services.user import get_or_create_user + router = Router() +@router.callback_query(SearchForm.showing_results, F.data.startswith("subscribe:")) +async def on_subscribe(callback: CallbackQuery, state: FSMContext, session: AsyncSession) -> None: + await callback.answer() + + data = await state.get_data() + entries = data.get("entries", []) + + index = int(callback.data.split(":", 1)[1]) + if index >= len(entries): + await callback.message.answer("Game not found. Please search again.") + return + + entry = entries[index] + game_info = GameInfo.from_dict(entry["game"]) + prices = {region: RegionPrice.from_dict(v) for region, v in entry["prices"].items()} + + user = await get_or_create_user(session, callback.from_user.id, callback.from_user.username) + + created = await subscribe_to_game(session, user, game_info, prices) + + if created: + await callback.message.answer( + f"🔔 Subscribed to {game_info.title}.\n" + "You'll be notified when the price drops." + ) + else: + await callback.message.answer( + f"You're already subscribed to {game_info.title} 🔔" + ) + + +@router.callback_query(SearchForm.showing_results, F.data.startswith("unsubscribe:")) +async def on_unsubscribe(callback: CallbackQuery, state: FSMContext, session: AsyncSession) -> None: + await callback.answer() + + data = await state.get_data() + entries = data.get("entries", []) + + index = int(callback.data.split(":", 1)[1]) + if index >= len(entries): + await callback.message.answer("Game not found. Please search again.") + return + + game_info = GameInfo.from_dict(entries[index]["game"]) + removed = await unsubscribe_from_game(session, callback.from_user.id, game_info.normalized_title) + + if removed: + await callback.message.answer(f"🔕 Unsubscribed from {game_info.title}.") + else: + await callback.message.answer(f"You're not subscribed to {game_info.title}.") + + @router.message(Command("subscribe")) async def cmd_subscribe(message: Message, session: AsyncSession) -> None: - await message.answer("Enter the game title to search:") + await message.answer("Search for a game first with /search, then use the Subscribe button on its card.") @router.message(Command("my")) diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py index 22581a8..7fe6ee4 100644 --- a/bot/keyboards/inline.py +++ b/bot/keyboards/inline.py @@ -46,6 +46,15 @@ def search_results_keyboard(games: list[GameInfo]) -> InlineKeyboardMarkup: return builder.as_markup() +def game_card_keyboard(game_index: int, is_subscribed: bool = False) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + if is_subscribed: + builder.button(text="🔕 Unsubscribe", callback_data=f"unsubscribe:{game_index}") + else: + builder.button(text="🔔 Subscribe", callback_data=f"subscribe:{game_index}") + return builder.as_markup() + + def cancel_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Cancel", callback_data="cancel") diff --git a/db/models/game.py b/db/models/game.py index c744afb..876c2ba 100644 --- a/db/models/game.py +++ b/db/models/game.py @@ -1,5 +1,7 @@ -from sqlalchemy import Index, String, Text +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, func from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -16,6 +18,7 @@ class Game(Base): cover_url: Mapped[str | None] = mapped_column(Text) game_type: Mapped[str | None] = mapped_column(String(64)) platforms: Mapped[list[str] | None] = mapped_column(ARRAY(String)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="game") game_regions: Mapped[list["GameRegion"]] = relationship(back_populates="game") diff --git a/db/models/game_region.py b/db/models/game_region.py index 54abb09..397a4ea 100644 --- a/db/models/game_region.py +++ b/db/models/game_region.py @@ -1,7 +1,7 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from db.base import Base @@ -21,6 +21,7 @@ class GameRegion(Base): discount_text: Mapped[str | None] = mapped_column(Text) discount_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) game: Mapped["Game"] = relationship(back_populates="game_regions") region: Mapped["Region"] = relationship(back_populates="game_regions") diff --git a/db/models/region.py b/db/models/region.py index 0e85269..281d135 100644 --- a/db/models/region.py +++ b/db/models/region.py @@ -1,5 +1,7 @@ -from sqlalchemy import String +from datetime import datetime + +from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from db.base import Base @@ -12,6 +14,7 @@ class Region(Base): code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False) currency: Mapped[str | None] = mapped_column(String(8), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) game_regions: Mapped[list["GameRegion"]] = relationship(back_populates="region") users: Mapped[list["User"]] = relationship( diff --git a/db/models/user_region.py b/db/models/user_region.py index 34fde97..d0101c1 100644 --- a/db/models/user_region.py +++ b/db/models/user_region.py @@ -1,4 +1,6 @@ -from sqlalchemy import ForeignKey +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from db.base import Base @@ -9,3 +11,4 @@ class UserRegion(Base): user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) region_id: Mapped[int] = mapped_column(ForeignKey("regions.id"), primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/migrations/versions/0004_add_created_at.py b/migrations/versions/0004_add_created_at.py new file mode 100644 index 0000000..0ae8aee --- /dev/null +++ b/migrations/versions/0004_add_created_at.py @@ -0,0 +1,28 @@ +"""add created_at to games, game_regions, regions, user_regions + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-05-17 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("games", sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column("game_regions", sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column("regions", sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column("user_regions", sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False)) + + +def downgrade() -> None: + op.drop_column("user_regions", "created_at") + op.drop_column("regions", "created_at") + op.drop_column("game_regions", "created_at") + op.drop_column("games", "created_at") diff --git a/services/ps_store.py b/services/ps_store.py index 0b5b996..82e031a 100644 --- a/services/ps_store.py +++ b/services/ps_store.py @@ -1,7 +1,7 @@ import json import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, field from urllib.parse import urlencode import aiohttp @@ -38,15 +38,6 @@ _WARN_STATUSES = {403, 404, 410, 429} -# Removes punctuation, trademark symbols, non-ASCII characters (Cyrillic, CJK, -# locale prefixes like "Набір", etc.), and whitespace so that titles collapse -# to the same key regardless of regional language prefix. -def normalize_title(title: str) -> str: - t = re.sub(r"[™®©:().,'\"!?\-/]", "", title.lower()) - t = re.sub(r"[^\x00-\x7f]", "", t) - return re.sub(r"\s+", "", t) - - @dataclass class RegionPrice: price: float | None @@ -77,6 +68,19 @@ class GameInfo: platforms: list[str] type: str cover_url: str | None + normalized_title: str = field(init=False) + + def __post_init__(self) -> None: + self.normalized_title = GameInfo.normalize_title(self.title) + + @staticmethod + def normalize_title(title: str) -> str: + # Removes punctuation, trademark symbols, non-ASCII characters (Cyrillic, CJK, + # locale prefixes like "Набір", etc.), and whitespace so that titles collapse + # to the same key regardless of regional language prefix. + t = re.sub(r"[™®©:().,'\"!?\-/]", "", title.lower()) + t = re.sub(r"[^\x00-\x7f]", "", t) + return re.sub(r"\s+", "", t) def to_dict(self) -> dict: return { @@ -235,6 +239,22 @@ def _gql_headers(region: str, referer: str) -> dict: } +_COUNTRY_TO_PS_PREFIX: dict[str, str] = { + "us": "UP", "ca": "UP", "mx": "UP", "br": "UP", "ar": "UP", "cl": "UP", "co": "UP", + "jp": "JP", + "kr": "KP", +} + + +def best_ps_id(region_code: str, ps_ids: dict[str, str]) -> str | None: + """Pick the ps_id most likely to work for region_code based on product ID prefix.""" + country = region_code.split("-")[-1].lower() + preferred = _COUNTRY_TO_PS_PREFIX.get(country, "EP") + return next((pid for pid in ps_ids.values() if pid.startswith(preferred)), None) + + +# Returns the price dict for the first outright purchase CTA, or None if the game +# is unavailable in the region (UNAVAILABLE type) or only free/PS Plus options exist. def _outright_price(webctas: list[dict]) -> dict | None: for cta in webctas: if cta.get("type") == "ADD_TO_CART": @@ -245,14 +265,19 @@ def _outright_price(webctas: list[dict]) -> dict | None: return None -async def search_games(query: str, region: str = "en-us") -> list[tuple[GameInfo, RegionPrice]]: +# Searches PS Store by text query and returns purchasable games with their prices. +# Free games (price=None after parsing) are excluded from results. +# page_size controls how many PS Store results are fetched; default is 50. +async def search_games( + query: str, region: str = "en-us", page_size: int = _GQL_SEARCH_PAGE_SIZE +) -> list[tuple[GameInfo, RegionPrice]]: _, _, country = region.partition("-") params = urlencode({ "operationName": "getSearchResults", "variables": json.dumps({ "countryCode": country.upper() if country else region.upper(), "languageCode": "en", - "pageSize": _GQL_SEARCH_PAGE_SIZE, + "pageSize": page_size, "searchTerm": query, "nextCursor": "", "pageOffset": 0, @@ -283,12 +308,13 @@ async def search_games(query: str, region: str = "en-us") -> list[tuple[GameInfo continue price_data = product.get("price") or {} price, currency, base_price, discount_text = _parse_str_price_data(price_data) - discounted = price_data.get("discountedPrice") - if price is None and not price_data.get("isFree") and discounted not in _NO_PRICE_STRINGS: - logger.warning( - "search_games: no price parsed [ps_id=%s region=%s raw=%r]", - product["id"], region, price_data, - ) + if price is None: + if not price_data.get("isFree") and price_data.get("discountedPrice") not in _NO_PRICE_STRINGS: + logger.warning( + "search_games: no price parsed [ps_id=%s region=%s raw=%r]", + product["id"], region, price_data, + ) + continue discount_end = _parse_end_time(price_data.get("endTime")) results.append(( _make_game_info(product), @@ -299,7 +325,11 @@ async def search_games(query: str, region: str = "en-us") -> list[tuple[GameInfo return results -async def get_game_info(ps_id: str, region: str = "en-us") -> tuple[GameInfo, RegionPrice | None] | None: +# Fetches full product data for a known ps_id in a specific region. +# Returns (GameInfo, RegionPrice) if the game exists and has a purchasable price. +# Returns None if the product is not found, the region doesn't carry it (UNAVAILABLE), +# or the game has no paid CTA (free or PS Plus only). +async def get_game_info(ps_id: str, region: str = "en-us") -> tuple[GameInfo, RegionPrice] | None: params = urlencode({ "operationName": "productRetrieveForUpsellWithCtas", "variables": json.dumps({"productId": ps_id}), @@ -325,32 +355,31 @@ async def get_game_info(ps_id: str, region: str = "en-us") -> tuple[GameInfo, Re products = (retrieve.get("concept") or {}).get("products") or [] product = next((p for p in products if p.get("id") == ps_id), None) if not product: - logger.warning("get_game_info: product not in concept.products [ps_id=%s region=%s]", ps_id, region) + logger.warning( + "get_game_info: product not in concept.products [ps_id=%s region=%s]", + ps_id, region, + ) return None - region_price: RegionPrice | None = None webctas = product.get("webctas") or [] price_cta = _outright_price(webctas) if price_cta is None: - logger.warning( - "get_game_info: no purchasable CTA [ps_id=%s region=%s webctas=%r]", - ps_id, region, webctas, - ) - if price_cta and not price_cta.get("isFree"): - iso = price_cta.get("currencyCode") - divisor = 1 if iso in _WHOLE_UNIT_CURRENCIES else 100 - dv = price_cta.get("discountedValue") - bv = price_cta.get("basePriceValue") - price = (dv if dv is not None else bv or 0) / divisor or None - base_price = bv / divisor if bv is not None and bv != dv else None - region_price = _make_region_price( - price=price, - currency=PS_ISO_TO_SYMBOL.get(iso, iso), - base_price=base_price, - discount_text=price_cta.get("discountText"), - ps_id=ps_id, - discount_end=_parse_end_time(price_cta.get("endTime")), - ) + return None + + iso = price_cta.get("currencyCode") + divisor = 1 if iso in _WHOLE_UNIT_CURRENCIES else 100 + dv = price_cta.get("discountedValue") + bv = price_cta.get("basePriceValue") + price = (dv if dv is not None else bv or 0) / divisor or None + base_price = bv / divisor if bv is not None and bv != dv else None + region_price = _make_region_price( + price=price, + currency=PS_ISO_TO_SYMBOL.get(iso, iso), + base_price=base_price, + discount_text=price_cta.get("discountText"), + ps_id=ps_id, + discount_end=_parse_end_time(price_cta.get("endTime")), + ) logger.info("get_game_info: found %r [ps_id=%s region=%s]", product.get("name"), ps_id, region) return _make_game_info(product), region_price diff --git a/services/region.py b/services/region.py index 83135ac..b3c21c6 100644 --- a/services/region.py +++ b/services/region.py @@ -1,7 +1,12 @@ +import logging + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import Region, UserRegion +from db.models.user import User + +logger = logging.getLogger(__name__) async def get_or_create_region( @@ -27,11 +32,11 @@ async def get_user_regions(session: AsyncSession, user_id: int) -> list[Region]: async def remove_user_region( - session: AsyncSession, user_id: int, region_id: int + session: AsyncSession, user: User, region_id: int ) -> None: result = await session.execute( select(UserRegion).where( - UserRegion.user_id == user_id, + UserRegion.user_id == user.id, UserRegion.region_id == region_id, ) ) @@ -39,19 +44,21 @@ async def remove_user_region( if row is not None: await session.delete(row) await session.commit() + logger.info("removed region telegram_id=%d region_id=%d", user.telegram_id, region_id) async def add_user_region( - session: AsyncSession, user_id: int, region_id: int + session: AsyncSession, user: User, region_id: int ) -> bool: exists = await session.execute( select(UserRegion).where( - UserRegion.user_id == user_id, + UserRegion.user_id == user.id, UserRegion.region_id == region_id, ) ) if exists.scalar_one_or_none() is not None: return False - session.add(UserRegion(user_id=user_id, region_id=region_id)) + session.add(UserRegion(user_id=user.id, region_id=region_id)) await session.commit() + logger.info("added region telegram_id=%d region_id=%d", user.telegram_id, region_id) return True diff --git a/services/subscription.py b/services/subscription.py new file mode 100644 index 0000000..5ed26a9 --- /dev/null +++ b/services/subscription.py @@ -0,0 +1,293 @@ +import asyncio +import logging +from datetime import datetime, timezone + +from sqlalchemy import exists, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from db.models.game import Game +from db.models.game_region import GameRegion +from db.models.region import Region +from db.models.subscription import Subscription +from db.models.user import User +from services.ps_store import GameInfo, RegionPrice, best_ps_id, get_game_info, search_games + +logger = logging.getLogger(__name__) + + +def _parse_discount_end(s: str | None) -> datetime | None: + if s is None: + return None + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + return None + + +def _make_game_region(game_id: int, region_id: int, rp: RegionPrice) -> GameRegion: + return GameRegion( + game_id=game_id, + region_id=region_id, + ps_id=rp.ps_id, + current_price=rp.price, + base_price=rp.base_price, + discount_text=rp.discount_text, + discount_end=_parse_discount_end(rp.discount_end), + ) + + +async def subscribe_to_game( + session: AsyncSession, + user: User, + game_info: GameInfo, + prices: dict[str, RegionPrice], +) -> bool: + """ + Ensure the game and its game_regions exist in the DB, then create a subscription. + Returns True if a new subscription was created, False if it already existed. + """ + normalized = game_info.normalized_title + region_codes = list(prices.keys()) + + # Single query: game + its game_regions (with region) + subscription for this user + stmt = ( + select(Game, GameRegion, Region, Subscription) + .outerjoin(GameRegion, GameRegion.game_id == Game.id) + .outerjoin(Region, Region.id == GameRegion.region_id) + .outerjoin( + Subscription, + (Subscription.game_id == Game.id) & (Subscription.user_id == user.id), + ) + .where(Game.normalized_title == normalized) + ) + rows = (await session.execute(stmt)).all() + + if not rows: + # Game doesn't exist yet — create game, game_regions, and subscription + game = Game( + title=game_info.title, + normalized_title=normalized, + cover_url=game_info.cover_url, + game_type=game_info.type, + platforms=game_info.platforms, + ) + session.add(game) + await session.flush() + + regions_result = await session.execute( + select(Region).where(Region.code.in_(region_codes)) + ) + regions_by_code = {r.code: r for r in regions_result.scalars()} + + for code, rp in prices.items(): + region = regions_by_code.get(code) + if region is None: + continue + if region.currency is None and rp.currency is not None: + region.currency = rp.currency + session.add(_make_game_region(game.id, region.id, rp)) + + session.add(Subscription(user_id=user.id, game_id=game.id)) + await session.commit() + logger.info( + "subscribed telegram_id=%d to new game %r regions=%s", + user.telegram_id, game_info.title, region_codes, + ) + return True + + # Game already exists + game: Game = rows[0][0] + existing_sub: Subscription | None = rows[0][3] + + if existing_sub is not None: + return False + + # Prefer ASCII title (same rule as in search merge: localized prefixes like "Набір" lose to ASCII) + if game_info.title.isascii() and not game.title.isascii(): + logger.info("updating title for game_id=%d: %r -> %r", game.id, game.title, game_info.title) + game.title = game_info.title + + # Build map of existing game_regions and regions by code + existing_grs: dict[str, GameRegion] = {} + existing_regions: dict[str, Region] = {} + for _, gr, region, _ in rows: + if gr is not None and region is not None: + existing_grs[region.code] = gr + existing_regions[region.code] = region + + # Fetch regions needed for any missing game_regions + missing_codes = [c for c in region_codes if c not in existing_grs] + regions_by_code: dict[str, Region] = {} + if missing_codes: + regions_result = await session.execute( + select(Region).where(Region.code.in_(missing_codes)) + ) + regions_by_code = {r.code: r for r in regions_result.scalars()} + + for code, rp in prices.items(): + region = existing_regions.get(code) or regions_by_code.get(code) + if region is not None and region.currency is None and rp.currency is not None: + region.currency = rp.currency + if code in existing_grs: + gr = existing_grs[code] + if gr.ps_id != rp.ps_id: + logger.warning( + "ps_id mismatch for %r in %s: db=%s current=%s", + game.title, code, gr.ps_id, rp.ps_id, + ) + else: + region = regions_by_code.get(code) + if region is None: + continue + logger.info("adding game_region game_id=%d region=%s ps_id=%s", game.id, code, rp.ps_id) + session.add(_make_game_region(game.id, region.id, rp)) + + session.add(Subscription(user_id=user.id, game_id=game.id)) + await session.commit() + logger.info( + "subscribed telegram_id=%d to existing game_id=%d %r", + user.telegram_id, game.id, game.title, + ) + return True + + +async def is_subscribed(session: AsyncSession, telegram_id: int, normalized_title: str) -> bool: + stmt = ( + select(Subscription.id) + .join(Game, Game.id == Subscription.game_id) + .join(User, User.id == Subscription.user_id) + .where(Game.normalized_title == normalized_title) + .where(User.telegram_id == telegram_id) + ) + return (await session.scalar(stmt)) is not None + + +async def unsubscribe_from_game( + session: AsyncSession, + telegram_id: int, + normalized_title: str, +) -> bool: + """Delete subscription. Returns True if it existed, False otherwise.""" + stmt = ( + select(Subscription) + .join(Game, Game.id == Subscription.game_id) + .join(User, User.id == Subscription.user_id) + .where(Game.normalized_title == normalized_title) + .where(User.telegram_id == telegram_id) + ) + sub = await session.scalar(stmt) + if sub is None: + return False + await session.delete(sub) + await session.commit() + logger.info("unsubscribed telegram_id=%d from %r", telegram_id, normalized_title) + return True + + +async def _find_region_price( + title: str, + normalized_title: str, + region_code: str, +) -> RegionPrice | None: + """Search PS Store by title and return the RegionPrice for the matching game. + + Fetches the top 5 results for `title` in `region_code` and returns the price + for the first result whose normalized title exactly matches `normalized_title`. + Returns None if no match is found (game unavailable or title mismatch). + """ + results = await search_games(title, region_code, page_size=5) + return next((rp for g, rp in results if g.normalized_title == normalized_title), None) + + +async def sync_subscriptions_for_new_region( + session: AsyncSession, + user: User, + region: Region, +) -> None: + """Create game_regions for a newly added region for all games the user is already subscribed to.""" + # Games subscribed by user that have no game_region for the new region yet, + # with an available ps_id from any existing game_region. + existing_gr = aliased(GameRegion) + stmt = ( + select(Game.id, Game.title, Game.normalized_title, Region.code, GameRegion.ps_id) + .join(Subscription, (Subscription.game_id == Game.id) & (Subscription.user_id == user.id)) + .join(GameRegion, GameRegion.game_id == Game.id) + .join(Region, Region.id == GameRegion.region_id) + .where(GameRegion.ps_id.is_not(None)) + .where( + ~exists().where( + (existing_gr.game_id == Game.id) & (existing_gr.region_id == region.id) + ) + ) + ) + rows = (await session.execute(stmt)).all() + + # Collect all ps_ids per game keyed by their region code, then pick the best one. + # Fall back to title search for games where no matching prefix ps_id is found. + game_meta: dict[int, tuple[str, str]] = {} # game_id -> (title, normalized_title) + game_ps_ids_by_region: dict[int, dict[str, str]] = {} + for game_id, title, normalized_title, region_code, ps_id in rows: + game_meta[game_id] = (title, normalized_title) + game_ps_ids_by_region.setdefault(game_id, {})[region_code] = ps_id + + if not game_ps_ids_by_region: + logger.info( + "sync: all game_regions already exist for region=%s telegram_id=%d", + region.code, user.telegram_id, + ) + return + + chosen: dict[int, str] = {} # game_id -> ps_id, resolved via get_game_info + to_search: list[tuple[int, str, str]] = [] # (game_id, title, normalized_title) + + for game_id, ps_ids in game_ps_ids_by_region.items(): + pid = best_ps_id(region.code, ps_ids) + if pid: + chosen[game_id] = pid + else: + to_search.append((game_id, *game_meta[game_id])) + + info_results, search_results = await asyncio.gather( + asyncio.gather(*[get_game_info(ps_id, region.code) for ps_id in chosen.values()]), + asyncio.gather(*[_find_region_price(title, norm, region.code) for _, title, norm in to_search]), + ) + + region_prices: list[tuple[int, RegionPrice]] = [] + + for (game_id, _), result in zip(chosen.items(), info_results): + if result is None: + continue + _, rp = result + if rp is not None: + region_prices.append((game_id, rp)) + + for (game_id, _, normalized_title), rp in zip(to_search, search_results): + if rp is None: + logger.info( + "sync fallback: no match for game_id=%d normalized=%r region=%s", + game_id, normalized_title, region.code, + ) + continue + region_prices.append((game_id, rp)) + + created = 0 + for game_id, rp in region_prices: + if region.currency is None and rp.currency is not None: + region.currency = rp.currency + session.add(_make_game_region(game_id, region.id, rp)) + created += 1 + + if created: + await session.commit() + logger.info( + "synced %d game_region(s) for region=%s telegram_id=%d", + created, region.code, user.telegram_id, + ) + else: + logger.info( + "sync: no games available in region=%s telegram_id=%d", + region.code, user.telegram_id, + ) diff --git a/services/user.py b/services/user.py index 2720aff..c366590 100644 --- a/services/user.py +++ b/services/user.py @@ -1,8 +1,12 @@ +import logging + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import User +logger = logging.getLogger(__name__) + async def get_or_create_user( session: AsyncSession, telegram_id: int, username: str | None @@ -13,4 +17,5 @@ async def get_or_create_user( user = User(telegram_id=telegram_id, username=username) session.add(user) await session.flush() + logger.info("created user telegram_id=%d username=%r", telegram_id, username) return user diff --git a/tests/integration/test_regions.py b/tests/integration/test_regions.py index aabf337..a9f3f02 100644 --- a/tests/integration/test_regions.py +++ b/tests/integration/test_regions.py @@ -33,7 +33,7 @@ async def test_get_user_regions_empty(session: AsyncSession, user): @pytest.mark.asyncio async def test_add_user_region(session: AsyncSession, user, region): - added = await add_user_region(session, user.id, region.id) + added = await add_user_region(session, user, region.id) assert added is True regions = await get_user_regions(session, user.id) @@ -43,15 +43,15 @@ async def test_add_user_region(session: AsyncSession, user, region): @pytest.mark.asyncio async def test_add_user_region_duplicate(session: AsyncSession, user, region): - await add_user_region(session, user.id, region.id) - added = await add_user_region(session, user.id, region.id) + await add_user_region(session, user, region.id) + added = await add_user_region(session, user, region.id) assert added is False @pytest.mark.asyncio async def test_remove_user_region(session: AsyncSession, user, region): - await add_user_region(session, user.id, region.id) - await remove_user_region(session, user.id, region.id) + await add_user_region(session, user, region.id) + await remove_user_region(session, user, region.id) regions = await get_user_regions(session, user.id) assert regions == [] @@ -59,7 +59,7 @@ async def test_remove_user_region(session: AsyncSession, user, region): @pytest.mark.asyncio async def test_remove_user_region_nonexistent(session: AsyncSession, user, region): - await remove_user_region(session, user.id, region.id) + await remove_user_region(session, user, region.id) regions = await get_user_regions(session, user.id) assert regions == [] diff --git a/tests/integration/test_subscription_service.py b/tests/integration/test_subscription_service.py new file mode 100644 index 0000000..6046f97 --- /dev/null +++ b/tests/integration/test_subscription_service.py @@ -0,0 +1,338 @@ +from decimal import Decimal +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import Game, GameRegion, Region, Subscription, User +from services.ps_store import GameInfo, RegionPrice +from services.subscription import ( + is_subscribed, + subscribe_to_game, + sync_subscriptions_for_new_region, + unsubscribe_from_game, +) + + +def _make_game_info(title: str = "Test Game", type_: str = "FULL_GAME") -> GameInfo: + return GameInfo(title=title, platforms=["PS5"], type=type_, cover_url=None) + + +def _make_region_price( + ps_id: str = "EP0001-PPSA00001_00-TESTGAME", + price: float | None = 49.99, + base_price: float | None = None, + currency: str | None = "$", + discount_end: str | None = None, +) -> RegionPrice: + return RegionPrice( + ps_id=ps_id, price=price, currency=currency, + base_price=base_price, discount_text=None, discount_end=discount_end, + ) + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture +async def region2(session: AsyncSession): + r = Region(code="en-us", name="United States") + session.add(r) + await session.flush() + return r + + +# ── subscribe_to_game ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_subscribe_new_game_creates_game(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + + await subscribe_to_game(session, user, game_info, prices) + + result = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + assert result is not None + assert result.title == "Test Game" + + +@pytest.mark.asyncio +async def test_subscribe_new_game_creates_game_region(session: AsyncSession, user, region): + game_info = _make_game_info() + rp = _make_region_price(ps_id="EP0001-PPSA00001_00-TESTGAME", price=39.99) + prices = {region.code: rp} + + await subscribe_to_game(session, user, game_info, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + gr = await session.scalar(select(GameRegion).where(GameRegion.game_id == game.id)) + assert gr is not None + assert gr.ps_id == "EP0001-PPSA00001_00-TESTGAME" + assert gr.current_price == Decimal("39.99") + assert gr.region_id == region.id + + +@pytest.mark.asyncio +async def test_subscribe_new_game_creates_subscription(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + + created = await subscribe_to_game(session, user, game_info, prices) + + assert created is True + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + sub = await session.scalar( + select(Subscription).where(Subscription.game_id == game.id, Subscription.user_id == user.id) + ) + assert sub is not None + + +@pytest.mark.asyncio +async def test_subscribe_new_game_populates_region_currency(session: AsyncSession, user, region): + assert region.currency is None + game_info = _make_game_info() + prices = {region.code: _make_region_price(currency="€")} + + await subscribe_to_game(session, user, game_info, prices) + + await session.refresh(region) + assert region.currency == "€" + + +@pytest.mark.asyncio +async def test_subscribe_existing_game_not_subscribed(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + + user2 = User(telegram_id=999999999, username="other") + session.add(user2) + await session.flush() + await subscribe_to_game(session, user2, game_info, prices) + + created = await subscribe_to_game(session, user, game_info, prices) + + assert created is True + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + sub = await session.scalar( + select(Subscription).where(Subscription.game_id == game.id, Subscription.user_id == user.id) + ) + assert sub is not None + + +@pytest.mark.asyncio +async def test_subscribe_existing_game_adds_missing_game_region(session: AsyncSession, user, region, region2): + game_info = _make_game_info() + prices_first = {region.code: _make_region_price(ps_id="EP0001")} + + user2 = User(telegram_id=999999999, username="other") + session.add(user2) + await session.flush() + await subscribe_to_game(session, user2, game_info, prices_first) + + prices_both = { + region.code: _make_region_price(ps_id="EP0001"), + region2.code: _make_region_price(ps_id="UP0001"), + } + await subscribe_to_game(session, user, game_info, prices_both) + + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + grs = (await session.scalars(select(GameRegion).where(GameRegion.game_id == game.id))).all() + region_ids = {gr.region_id for gr in grs} + assert region.id in region_ids + assert region2.id in region_ids + + +@pytest.mark.asyncio +async def test_subscribe_already_subscribed_returns_false(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + + await subscribe_to_game(session, user, game_info, prices) + created = await subscribe_to_game(session, user, game_info, prices) + + assert created is False + + +@pytest.mark.asyncio +async def test_subscribe_prefers_ascii_title(session: AsyncSession, user, region): + localized = _make_game_info(title="Набір Test Game") + ascii_game = _make_game_info(title="Test Game") + prices = {region.code: _make_region_price()} + + user2 = User(telegram_id=999999999, username="other") + session.add(user2) + await session.flush() + await subscribe_to_game(session, user2, localized, prices) + + await subscribe_to_game(session, user, ascii_game, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == ascii_game.normalized_title)) + assert game.title == "Test Game" + + +@pytest.mark.asyncio +async def test_subscribe_keeps_title_if_new_is_non_ascii(session: AsyncSession, user, region): + ascii_game = _make_game_info(title="Test Game") + localized = _make_game_info(title="Набір Test Game") + prices = {region.code: _make_region_price()} + + user2 = User(telegram_id=999999999, username="other") + session.add(user2) + await session.flush() + await subscribe_to_game(session, user2, ascii_game, prices) + + await subscribe_to_game(session, user, localized, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == ascii_game.normalized_title)) + assert game.title == "Test Game" + + + +# ── is_subscribed ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_is_subscribed_true(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + await subscribe_to_game(session, user, game_info, prices) + + result = await is_subscribed(session, user.telegram_id, game_info.normalized_title) + + assert result is True + + +@pytest.mark.asyncio +async def test_is_subscribed_false(session: AsyncSession, user): + result = await is_subscribed(session, user.telegram_id, "nonexistentgame") + + assert result is False + + +# ── unsubscribe_from_game ───────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_unsubscribe_removes_subscription(session: AsyncSession, user, region): + game_info = _make_game_info() + prices = {region.code: _make_region_price()} + await subscribe_to_game(session, user, game_info, prices) + + removed = await unsubscribe_from_game(session, user.telegram_id, game_info.normalized_title) + + assert removed is True + result = await is_subscribed(session, user.telegram_id, game_info.normalized_title) + assert result is False + + +@pytest.mark.asyncio +async def test_unsubscribe_not_subscribed_returns_false(session: AsyncSession, user): + removed = await unsubscribe_from_game(session, user.telegram_id, "nonexistentgame") + + assert removed is False + + +# ── sync_subscriptions_for_new_region ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_sync_creates_game_regions_for_new_region(session: AsyncSession, user, region, region2): + game_info = _make_game_info() + prices = {region.code: _make_region_price(ps_id="EP0001-PPSA00001_00-TESTGAME")} + await subscribe_to_game(session, user, game_info, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + + mock_rp = RegionPrice(ps_id="UP0001-PPSA00001_00-TESTGAME", price=59.99, currency="$", + base_price=None, discount_text=None) + with patch("services.subscription._find_region_price", new=AsyncMock(return_value=mock_rp)): + await sync_subscriptions_for_new_region(session, user, region2) + + gr = await session.scalar( + select(GameRegion).where(GameRegion.game_id == game.id, GameRegion.region_id == region2.id) + ) + assert gr is not None + assert gr.current_price == Decimal("59.99") + + +@pytest.mark.asyncio +async def test_sync_populates_region_currency(session: AsyncSession, user, region, region2): + game_info = _make_game_info() + prices = {region.code: _make_region_price(ps_id="EP0001")} + await subscribe_to_game(session, user, game_info, prices) + + mock_rp = RegionPrice(ps_id="UP0001", price=59.99, currency="$", base_price=None, discount_text=None) + assert region2.currency is None + with patch("services.subscription._find_region_price", new=AsyncMock(return_value=mock_rp)): + await sync_subscriptions_for_new_region(session, user, region2) + + await session.refresh(region2) + assert region2.currency == "$" + + +@pytest.mark.asyncio +async def test_sync_skips_existing_game_region(session: AsyncSession, user, region, region2): + game_info = _make_game_info() + prices = { + region.code: _make_region_price(ps_id="EP0001"), + region2.code: _make_region_price(ps_id="UP0001"), + } + await subscribe_to_game(session, user, game_info, prices) + + mock_get = AsyncMock(return_value=None) + with patch("services.subscription.get_game_info", new=mock_get): + await sync_subscriptions_for_new_region(session, user, region2) + + mock_get.assert_not_called() + + +@pytest.mark.asyncio +async def test_sync_no_subscriptions_does_nothing(session: AsyncSession, user, region2): + mock_get = AsyncMock() + with patch("services.subscription.get_game_info", new=mock_get): + await sync_subscriptions_for_new_region(session, user, region2) + + mock_get.assert_not_called() + + +@pytest.mark.asyncio +async def test_sync_via_get_game_info_when_prefix_matches(session: AsyncSession, user, region): + """best_ps_id finds an EP id for a new EU region → get_game_info is called, game_region created.""" + eu_region2 = Region(code="de-de", name="Germany") + session.add(eu_region2) + await session.flush() + + game_info = _make_game_info() + prices = {region.code: _make_region_price(ps_id="EP0001-PPSA00001_00-TESTGAME")} + await subscribe_to_game(session, user, game_info, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + + mock_rp = RegionPrice(ps_id="EP0001-PPSA00001_00-TESTGAME", price=39.99, currency="€", + base_price=None, discount_text=None) + mock_get = AsyncMock(return_value=(game_info, mock_rp)) + with patch("services.subscription.get_game_info", new=mock_get): + await sync_subscriptions_for_new_region(session, user, eu_region2) + + mock_get.assert_called_once_with("EP0001-PPSA00001_00-TESTGAME", eu_region2.code) + gr = await session.scalar( + select(GameRegion).where(GameRegion.game_id == game.id, GameRegion.region_id == eu_region2.id) + ) + assert gr is not None + assert gr.current_price == Decimal("39.99") + + +@pytest.mark.asyncio +async def test_sync_game_unavailable_in_new_region(session: AsyncSession, user, region, region2): + """_find_region_price returns None (no title match in US store) → no game_region created.""" + game_info = _make_game_info() + prices = {region.code: _make_region_price(ps_id="EP0001")} + await subscribe_to_game(session, user, game_info, prices) + + game = await session.scalar(select(Game).where(Game.normalized_title == game_info.normalized_title)) + + with patch("services.subscription._find_region_price", new=AsyncMock(return_value=None)): + await sync_subscriptions_for_new_region(session, user, region2) + + gr = await session.scalar( + select(GameRegion).where(GameRegion.game_id == game.id, GameRegion.region_id == region2.id) + ) + assert gr is None diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py index 1152218..e97628c 100644 --- a/tests/unit/test_formatters.py +++ b/tests/unit/test_formatters.py @@ -1,3 +1,5 @@ +import dataclasses + from bot.formatters import ( _card_price_lines, _format_price, @@ -64,7 +66,7 @@ def test_game_header_full_game(): def test_game_header_premium_edition(): - game = GameInfo(**{**GAME.__dict__, "type": "PREMIUM_EDITION", "platforms": ["PS4", "PS5"]}) + game = dataclasses.replace(GAME, type="PREMIUM_EDITION", platforms=["PS4", "PS5"]) lines = _game_header(game) assert lines[0].startswith("💎") assert "Premium Edition" in lines[1] @@ -72,14 +74,14 @@ def test_game_header_premium_edition(): def test_game_header_unknown_type(): - game = GameInfo(**{**GAME.__dict__, "type": "UNKNOWN_TYPE"}) + game = dataclasses.replace(GAME, type="UNKNOWN_TYPE") lines = _game_header(game) assert lines[0].startswith("🎮") assert "UNKNOWN_TYPE" in lines[1] def test_game_header_no_platforms(): - game = GameInfo(**{**GAME.__dict__, "platforms": []}) + game = dataclasses.replace(GAME, platforms=[]) lines = _game_header(game) assert lines[1].startswith("—") diff --git a/tests/unit/test_keyboards.py b/tests/unit/test_keyboards.py new file mode 100644 index 0000000..4b6f244 --- /dev/null +++ b/tests/unit/test_keyboards.py @@ -0,0 +1,20 @@ +from bot.keyboards.inline import game_card_keyboard + + +def test_game_card_keyboard_subscribe_by_default(): + kb = game_card_keyboard(0) + button = kb.inline_keyboard[0][0] + assert button.text == "🔔 Subscribe" + assert button.callback_data == "subscribe:0" + + +def test_game_card_keyboard_unsubscribe(): + kb = game_card_keyboard(3, is_subscribed=True) + button = kb.inline_keyboard[0][0] + assert button.text == "🔕 Unsubscribe" + assert button.callback_data == "unsubscribe:3" + + +def test_game_card_keyboard_index_in_callback(): + kb = game_card_keyboard(7) + assert kb.inline_keyboard[0][0].callback_data == "subscribe:7" diff --git a/tests/unit/test_ps_store.py b/tests/unit/test_ps_store.py index b7d6fc8..be3ec50 100644 --- a/tests/unit/test_ps_store.py +++ b/tests/unit/test_ps_store.py @@ -1,50 +1,50 @@ import pytest -from services.ps_store import GameInfo, RegionPrice, get_game_info, normalize_title, search_games +from services.ps_store import GameInfo, RegionPrice, get_game_info, search_games # --- normalize_title --- def test_normalize_title_lowercase(): - assert normalize_title("FINAL FANTASY") == "finalfantasy" + assert GameInfo.normalize_title("FINAL FANTASY") == "finalfantasy" def test_normalize_title_trademark(): - assert normalize_title("The Last of Us™") == normalize_title("The Last of Us") + assert GameInfo.normalize_title("The Last of Us™") == GameInfo.normalize_title("The Last of Us") def test_normalize_title_registered(): - assert normalize_title("FINAL FANTASY® VII") == normalize_title("FINAL FANTASY VII") + assert GameInfo.normalize_title("FINAL FANTASY® VII") == GameInfo.normalize_title("FINAL FANTASY VII") def test_normalize_title_colon_parentheses(): - assert normalize_title("God of War: Ragnarök") == normalize_title("God of War Ragnarök") + assert GameInfo.normalize_title("God of War: Ragnarök") == GameInfo.normalize_title("God of War Ragnarök") def test_normalize_title_standalone_vs_stand_alone(): - assert normalize_title("Left Behind (Standalone)") == normalize_title("Left Behind Stand Alone") + assert GameInfo.normalize_title("Left Behind (Standalone)") == GameInfo.normalize_title("Left Behind Stand Alone") def test_normalize_title_strips_korean_suffix(): assert ( - normalize_title("FINAL FANTASY XV ROYAL EDITION (중국어, 한국어)") - == normalize_title("FINAL FANTASY XV ROYAL EDITION") + GameInfo.normalize_title("FINAL FANTASY XV ROYAL EDITION (중국어, 한국어)") + == GameInfo.normalize_title("FINAL FANTASY XV ROYAL EDITION") ) def test_normalize_title_strips_japanese_chars(): assert ( - normalize_title("FINAL FANTASY VII リメイク") - == normalize_title("FINAL FANTASY VII") + GameInfo.normalize_title("FINAL FANTASY VII リメイク") + == GameInfo.normalize_title("FINAL FANTASY VII") ) def test_normalize_title_strips_cyrillic_prefix(): assert ( - normalize_title("Набір FINAL FANTASY VII REMAKE & REBIRTH Twin Pack") - == normalize_title("FINAL FANTASY VII REMAKE & REBIRTH Twin Pack") + GameInfo.normalize_title("Набір FINAL FANTASY VII REMAKE & REBIRTH Twin Pack") + == GameInfo.normalize_title("FINAL FANTASY VII REMAKE & REBIRTH Twin Pack") ) def test_normalize_title_collapses_spaces(): - assert normalize_title("God of War") == normalize_title("God of War") + assert GameInfo.normalize_title("God of War") == GameInfo.normalize_title("God of War") def test_normalize_title_preserves_numbers(): - assert normalize_title("FIFA 23") == "fifa23" + assert GameInfo.normalize_title("FIFA 23") == "fifa23" def test_normalize_title_numbers_across_regions(): - assert normalize_title("FIFA 23 (중국어, 한국어)") == normalize_title("FIFA 23") + assert GameInfo.normalize_title("FIFA 23 (중국어, 한국어)") == GameInfo.normalize_title("FIFA 23") # --- fixtures --- @@ -183,7 +183,7 @@ async def test_search_no_cover_url(mock_store): @pytest.mark.asyncio -async def test_search_free_price_is_none(make_mock_store): +async def test_search_free_game_excluded(make_mock_store): make_mock_store({"data": {"universalSearch": {"results": [ { "__typename": "Product", @@ -197,9 +197,7 @@ async def test_search_free_price_is_none(make_mock_store): ]}}}) results = await search_games("spider man") - _, price = results[0] - assert price.price is None - assert price.currency is None + assert results == [] @pytest.mark.asyncio @@ -317,6 +315,29 @@ async def test_get_game_info_not_found_returns_none(make_mock_store): assert result is None +def test_game_info_normalized_title_auto_computed(): + game = GameInfo(title="God of War: Ragnarök™", platforms=["PS5"], type="FULL_GAME", cover_url=None) + assert game.normalized_title == GameInfo.normalize_title("God of War: Ragnarök™") + assert game.normalized_title == "godofwarragnark" + + +@pytest.mark.asyncio +async def test_get_game_info_unavailable_returns_none_price(make_mock_store): + ps_id = "EP0001-PPSA00001_00-REGIONLOCKED" + make_mock_store({"data": {"productRetrieve": {"concept": {"products": [{ + "id": ps_id, + "name": "Region Locked Game", + "platforms": ["PS5"], + "storeDisplayClassification": "FULL_GAME", + "media": [], + "webctas": [{"type": "UNAVAILABLE", "meta": None, "price": None}], + }]}}}}) + + result = await get_game_info(ps_id, "en-us") + + assert result is None + + @pytest.mark.asyncio async def test_get_game_info_skips_free_trial_cta(make_mock_store): """Free trial CTA appears first; paid CTA must still be found.""" diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py index 007a11e..dd4df6e 100644 --- a/tests/unit/test_search.py +++ b/tests/unit/test_search.py @@ -2,8 +2,8 @@ import pytest -from bot.handlers.search import _best_ps_id, _do_search -from services.ps_store import GameInfo, RegionPrice +from bot.handlers.search import _do_search +from services.ps_store import GameInfo, RegionPrice, best_ps_id UP_ID = "UP9000-PPSA03016_00-GAME" EP_ID = "EP9000-CUSA12345_00-GAME" @@ -11,36 +11,36 @@ KP_ID = "KP9000-PPSA00001_00-GAME" -# --- _best_ps_id --- +# --- best_ps_id (from services.ps_store) --- -def test_best_ps_id_eu_prefers_ep(): +def testbest_ps_id_eu_prefers_ep(): ps_ids = {"en-us": UP_ID, "en-gb": EP_ID} - assert _best_ps_id("en-pl", ps_ids) == EP_ID + assert best_ps_id("en-pl", ps_ids) == EP_ID -def test_best_ps_id_us_prefers_up(): +def testbest_ps_id_us_prefers_up(): ps_ids = {"en-gb": EP_ID, "en-us": UP_ID} - assert _best_ps_id("en-us", ps_ids) == UP_ID + assert best_ps_id("en-us", ps_ids) == UP_ID -def test_best_ps_id_jp_prefers_jp(): +def testbest_ps_id_jp_prefers_jp(): ps_ids = {"en-gb": EP_ID, "ja-jp": JP_ID} - assert _best_ps_id("ja-jp", ps_ids) == JP_ID + assert best_ps_id("ja-jp", ps_ids) == JP_ID -def test_best_ps_id_kr_prefers_kp(): +def testbest_ps_id_kr_prefers_kp(): ps_ids = {"en-gb": EP_ID, "ko-kr": KP_ID} - assert _best_ps_id("ko-kr", ps_ids) == KP_ID + assert best_ps_id("ko-kr", ps_ids) == KP_ID -def test_best_ps_id_eu_no_ep_returns_none(): - assert _best_ps_id("en-pl", {"en-us": UP_ID}) is None +def testbest_ps_id_eu_no_ep_returns_none(): + assert best_ps_id("en-pl", {"en-us": UP_ID}) is None -def test_best_ps_id_us_no_up_returns_none(): - assert _best_ps_id("en-us", {"en-gb": EP_ID}) is None +def testbest_ps_id_us_no_up_returns_none(): + assert best_ps_id("en-us", {"en-gb": EP_ID}) is None -def test_best_ps_id_empty_returns_none(): - assert _best_ps_id("en-gb", {}) is None +def testbest_ps_id_empty_returns_none(): + assert best_ps_id("en-gb", {}) is None -def test_best_ps_id_multiple_ep_returns_first(): +def testbest_ps_id_multiple_ep_returns_first(): ps_ids = {"en-gb": EP_ID, "en-pl": "EP1111-CUSA00000_00-OTHER"} - result = _best_ps_id("de-de", ps_ids) + result = best_ps_id("de-de", ps_ids) assert result is not None assert result.startswith("EP") @@ -190,50 +190,6 @@ async def test_fallback_fires_per_region(mocker, common_mocks): assert calls == {(EP_ID, "de-de"), (EP_ID, "fr-fr")} -@pytest.mark.asyncio -async def test_free_games_excluded_from_results(mocker, common_mocks): - """Games with no price in any region (free/demo) should not appear in the list.""" - regions = [_region("en-us")] - mocker.patch("bot.handlers.search.get_user_regions", new_callable=AsyncMock, return_value=regions) - - paid_game = _make_game(UP_ID, title="Paid Game", price=39.99, currency="$") - free_game = _make_game("UP9000-PPSA99999_00-FREE", title="Free Game Demo", price=None, currency=None) - mocker.patch("bot.handlers.search.search_games", new_callable=AsyncMock, return_value=[paid_game, free_game]) - mocker.patch("bot.handlers.search.get_game_info", new_callable=AsyncMock, return_value=None) - - state = AsyncMock() - captured = {} - state.update_data = AsyncMock(side_effect=lambda **kw: captured.update(kw)) - - await _do_search(_make_message(), state, AsyncMock(), "game") - - entries = captured.get("entries", []) - titles = [e["game"]["title"] for e in entries] - assert "Paid Game" in titles - assert "Free Game Demo" not in titles - - -@pytest.mark.asyncio -async def test_free_game_excluded_after_fallback(mocker, common_mocks): - """Game found via fallback with price=None should still be excluded.""" - regions = [_region("en-gb"), _region("de-de")] - mocker.patch("bot.handlers.search.get_user_regions", new_callable=AsyncMock, return_value=regions) - - free_game = _make_game(EP_ID, title="Free Demo", price=None, currency=None) - mocker.patch("bot.handlers.search.search_games", new_callable=AsyncMock, side_effect=[[free_game], []]) - - fallback_free = _make_game(EP_ID, title="Free Demo", price=None, currency=None) - mocker.patch("bot.handlers.search.get_game_info", new_callable=AsyncMock, return_value=fallback_free) - - state = AsyncMock() - captured = {} - state.update_data = AsyncMock(side_effect=lambda **kw: captured.update(kw)) - - await _do_search(_make_message(), state, AsyncMock(), "free demo") - - entries = captured.get("entries", []) - assert len(entries) == 0 - @pytest.mark.asyncio async def test_fallback_result_merged_into_prices(mocker, common_mocks): @@ -265,23 +221,3 @@ async def capture_update_data(**kwargs): assert prices["de-de"]["price"] == 39.99 -@pytest.mark.asyncio -async def test_paid_price_not_overwritten_by_free(mocker, common_mocks): - """If a region returns both a paid and a free product with the same title, the paid price wins.""" - regions = [_region("ja-jp")] - mocker.patch("bot.handlers.search.get_user_regions", new_callable=AsyncMock, return_value=regions) - - paid = _make_game(JP_ID, title="Minecraft", price=2640.0, currency="¥") - free = _make_game("JP0127-CUSA00283_00-MINECRAFTPS40000", title="Minecraft", price=None, currency=None) - mocker.patch("bot.handlers.search.search_games", new_callable=AsyncMock, return_value=[paid, free]) - mocker.patch("bot.handlers.search.get_game_info", new_callable=AsyncMock, return_value=None) - - state = AsyncMock() - captured = {} - state.update_data = AsyncMock(side_effect=lambda **kw: captured.update(kw)) - - await _do_search(_make_message(), state, AsyncMock(), "minecraft") - - entries = captured.get("entries", []) - assert len(entries) == 1 - assert entries[0]["prices"]["ja-jp"]["price"] == 2640.0