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