From 810d98c875c0623bff4d44b2dcff5ca905195434 Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sun, 19 Jul 2026 11:54:00 +0200 Subject: [PATCH] Add DLC priority: scan DLC listings, bypass autojoin filters after wishlist DLC listings on SteamGifts only show content for games the user owns, so they get the same treatment as wishlist giveaways: the DLC scan now runs every automation cycle and maintains an is_dlc flag, and the renamed dlc_priority_enabled setting (was dlc_enabled, value carried over by the migration) makes flagged giveaways skip the quality filters and enter right after wishlist ones. DLC entries are recorded with entry_type "dlc" and badged in the UI. --- .../e9a3b6c4d2f7_add_giveaway_is_dlc.py | 79 +++++++++++++++++++ backend/src/api/schemas/giveaway.py | 5 ++ backend/src/api/schemas/settings.py | 10 +-- backend/src/models/giveaway.py | 5 ++ backend/src/models/settings.py | 7 +- backend/src/repositories/giveaway.py | 51 +++++++----- backend/src/services/eligibility.py | 10 +++ backend/src/services/giveaway_query.py | 11 ++- backend/src/services/giveaway_sync.py | 27 ++++--- backend/src/services/settings_service.py | 2 +- backend/src/utils/steamgifts_client.py | 4 +- backend/src/utils/steamgifts_parser.py | 7 +- backend/src/workers/automation.py | 44 +++++------ backend/src/workers/processor.py | 8 +- .../tests/unit/test_api_routers_settings.py | 2 +- backend/tests/unit/test_eligibility.py | 45 +++++++++-- backend/tests/unit/test_models_settings.py | 6 +- .../tests/unit/test_repositories_settings.py | 2 +- backend/tests/unit/test_schemas_settings.py | 4 +- .../unit/test_services_giveaway_service.py | 24 ++++++ backend/tests/unit/test_workers_automation.py | 3 +- backend/tests/unit/test_workers_processor.py | 39 ++++++++- frontend/e2e/mocks.ts | 3 +- frontend/src/hooks/useGiveaways.test.tsx | 1 + frontend/src/hooks/useSettings.test.tsx | 10 +-- frontend/src/pages/Giveaways.tsx | 8 +- frontend/src/pages/History.tsx | 1 + frontend/src/pages/Settings.tsx | 16 ++-- frontend/src/types/index.ts | 5 +- 29 files changed, 342 insertions(+), 97 deletions(-) create mode 100644 backend/src/alembic/versions/e9a3b6c4d2f7_add_giveaway_is_dlc.py diff --git a/backend/src/alembic/versions/e9a3b6c4d2f7_add_giveaway_is_dlc.py b/backend/src/alembic/versions/e9a3b6c4d2f7_add_giveaway_is_dlc.py new file mode 100644 index 0000000..7c7cefb --- /dev/null +++ b/backend/src/alembic/versions/e9a3b6c4d2f7_add_giveaway_is_dlc.py @@ -0,0 +1,79 @@ +"""add is_dlc flag and dlc_priority_enabled setting + +DLC listings on SteamGifts are for games the user owns, so DLC giveaways get +the same autojoin priority treatment as wishlist ones. The is_dlc flag records +which giveaways came from a DLC scan (now part of every scan, like wishlist); +dlc_priority_enabled replaces dlc_enabled and only controls the autojoin +priority/bypass, not the scanning. + +Revision ID: e9a3b6c4d2f7 +Revises: d4e7f2a8b1c5 +Create Date: 2026-07-19 10:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'e9a3b6c4d2f7' +down_revision: str | Sequence[str] | None = 'd4e7f2a8b1c5' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'is_dlc', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + comment='DLC giveaway (DLC listings are for games the user owns)', + ) + ) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'dlc_priority_enabled', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + comment='DLC giveaways bypass autojoin filters and are entered ' + 'after wishlist ones', + ) + ) + + # Carry the old opt-in over: users who entered DLC giveaways before keep + # entering them (now with priority) instead of being silently reset. + op.execute('UPDATE settings SET dlc_priority_enabled = dlc_enabled') + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('dlc_enabled') + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'dlc_enabled', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + comment='Whether to enter DLC giveaways', + ) + ) + + op.execute('UPDATE settings SET dlc_enabled = dlc_priority_enabled') + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('dlc_priority_enabled') + + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.drop_column('is_dlc') diff --git a/backend/src/api/schemas/giveaway.py b/backend/src/api/schemas/giveaway.py index 6f8d710..2f3f722 100644 --- a/backend/src/api/schemas/giveaway.py +++ b/backend/src/api/schemas/giveaway.py @@ -77,6 +77,11 @@ class GiveawayBase(BaseModel): description="Whether game is on user's Steam wishlist", examples=[False], ) + is_dlc: bool = Field( + default=False, + description="DLC giveaway (DLC listings are for games the user owns)", + examples=[False], + ) is_won: bool = Field( default=False, description="Whether user has won this giveaway", diff --git a/backend/src/api/schemas/settings.py b/backend/src/api/schemas/settings.py index e6f54b6..b66ecaf 100644 --- a/backend/src/api/schemas/settings.py +++ b/backend/src/api/schemas/settings.py @@ -34,9 +34,9 @@ class SettingsBase(BaseModel): ) # DLC Settings - dlc_enabled: bool = Field( + dlc_priority_enabled: bool = Field( default=False, - description="Whether to enter DLC giveaways", + description="DLC giveaways bypass autojoin filters and are entered after wishlist ones", examples=[False], ) @@ -203,7 +203,7 @@ class SettingsResponse(SettingsBase): "phpsessid": "abc123...", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/82.0", "xsrf_token": None, - "dlc_enabled": False, + "dlc_priority_enabled": False, "autojoin_enabled": True, "autojoin_start_at": 350, "autojoin_stop_at": 200, @@ -253,9 +253,9 @@ class SettingsUpdate(BaseModel): ) # DLC Settings - dlc_enabled: bool | None = Field( + dlc_priority_enabled: bool | None = Field( default=None, - description="Whether to enter DLC giveaways", + description="DLC giveaways bypass autojoin filters and are entered after wishlist ones", ) # Safety Settings diff --git a/backend/src/models/giveaway.py b/backend/src/models/giveaway.py index e5a2e0c..8589513 100644 --- a/backend/src/models/giveaway.py +++ b/backend/src/models/giveaway.py @@ -141,6 +141,11 @@ class Giveaway(Base, TimestampMixin): default=False, comment="Game is on user's Steam wishlist", ) + is_dlc: Mapped[bool] = mapped_column( + Boolean, + default=False, + comment="DLC giveaway (DLC listings are for games the user owns)", + ) is_won: Mapped[bool] = mapped_column( Boolean, default=False, diff --git a/backend/src/models/settings.py b/backend/src/models/settings.py index 0ff8c37..6b9bbb5 100644 --- a/backend/src/models/settings.py +++ b/backend/src/models/settings.py @@ -24,7 +24,8 @@ class Settings(Base, TimestampMixin): xsrf_token: Anti-CSRF token from SteamGifts (extracted from pages) DLC Settings: - dlc_enabled: Whether to enter DLC giveaways (default: False) + dlc_priority_enabled: DLC giveaways bypass autojoin filters and are + entered after wishlist ones (default: False) Auto-join Settings: autojoin_enabled: Enable automatic giveaway entry (default: False) @@ -89,10 +90,10 @@ class Settings(Base, TimestampMixin): # NOTE: current_points is fetched dynamically from SteamGifts, not stored here # ==================== DLC Settings ==================== - dlc_enabled: Mapped[bool] = mapped_column( + dlc_priority_enabled: Mapped[bool] = mapped_column( Boolean, default=False, - comment="Whether to enter DLC giveaways", + comment="DLC giveaways bypass autojoin filters and are entered after wishlist ones", ) # ==================== Safety Settings ==================== diff --git a/backend/src/repositories/giveaway.py b/backend/src/repositories/giveaway.py index f5d9d7a..ee3a199 100644 --- a/backend/src/repositories/giveaway.py +++ b/backend/src/repositories/giveaway.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from typing import Any -from sqlalchemy import and_, or_, select, update +from sqlalchemy import ColumnElement, and_, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from core.time import utcnow @@ -205,6 +205,7 @@ async def get_eligible( max_game_age: int | None = None, limit: int | None = None, wishlist_priority: bool = True, + dlc_priority: bool = False, ) -> list[Giveaway]: """ Get eligible giveaways based on autojoin criteria. @@ -219,7 +220,8 @@ async def get_eligible( When ``wishlist_priority`` is on (default), wishlist giveaways bypass the price and game-quality filters (the user explicitly wants those games) and sort before everything else. When off, they pass the same - filters as everything else. + filters as everything else. ``dlc_priority`` grants DLC giveaways the + same bypass (DLC listings are for owned games), sorted after wishlist. Args: min_price: Minimum giveaway price in points @@ -286,15 +288,21 @@ async def get_eligible( min_release_date = f"{now.year - max_game_age}-01-01" filter_conditions.append(Game.release_date >= min_release_date) + # Bypass branches: a giveaway qualifies by passing the full filter + # set, or via an active priority flag (mirrors evaluate_eligibility). + qualifying: list[ColumnElement[bool]] = [and_(*filter_conditions)] + order_by: list[ColumnElement[Any]] = [] if wishlist_priority: - query = query.where( - and_(*base_conditions), - or_(self.model.is_wishlist == True, and_(*filter_conditions)), # noqa: E712 - ).order_by(self.model.is_wishlist.desc(), self.model.price.desc()) - else: - query = query.where( - and_(*base_conditions), and_(*filter_conditions) - ).order_by(self.model.price.desc()) + qualifying.append(self.model.is_wishlist == True) # noqa: E712 + order_by.append(self.model.is_wishlist.desc()) + if dlc_priority: + qualifying.append(self.model.is_dlc == True) # noqa: E712 + order_by.append(self.model.is_dlc.desc()) + order_by.append(self.model.price.desc()) + + query = query.where( + and_(*base_conditions), or_(*qualifying) + ).order_by(*order_by) if limit: query = query.limit(limit) @@ -436,16 +444,19 @@ async def get_wishlist( result = await self.session.execute(query) return list(result.scalars().all()) - async def unset_wishlist_except(self, codes: set[str]) -> int: + async def unset_flag_except(self, flag: str, codes: set[str]) -> int: """ - Clear the wishlist flag on active giveaways not in ``codes``. + Clear a scan-derived flag on active giveaways not in ``codes``. - Called after a *complete* wishlist scan: any active giveaway still - flagged wishlist but absent from the scan is no longer on the user's - Steam wishlist. Expired giveaways keep their flag as history. + Called after a *complete* scan of the matching type: any active + giveaway still flagged but absent from the scan no longer belongs to + that listing (e.g. game removed from the Steam wishlist, or no longer + shown as owned-game DLC). Expired giveaways keep their flag as + history. Args: - codes: Giveaway codes seen in the completed wishlist scan + flag: Column name, "is_wishlist" or "is_dlc" + codes: Giveaway codes seen in the completed scan Returns: Number of giveaways whose flag was cleared @@ -454,16 +465,20 @@ async def unset_wishlist_except(self, codes: set[str]) -> int: This method does NOT commit the transaction. The caller must call session.commit() to persist changes to the database. """ + if flag not in ("is_wishlist", "is_dlc"): + raise ValueError(f"Unsupported scan flag: {flag}") + column = getattr(self.model, flag) + now = utcnow() stmt = ( update(self.model) .where( - self.model.is_wishlist == True, # noqa: E712 + column == True, # noqa: E712 self.model.end_time.isnot(None), self.model.end_time > now, self.model.code.notin_(codes), ) - .values(is_wishlist=False) + .values(**{flag: False}) ) result = await self.session.execute(stmt) # execute() is typed as Result, but UPDATE always yields a CursorResult. diff --git a/backend/src/services/eligibility.py b/backend/src/services/eligibility.py index cd8af7a..163b200 100644 --- a/backend/src/services/eligibility.py +++ b/backend/src/services/eligibility.py @@ -17,6 +17,10 @@ game-quality criteria entirely — being on the user's Steam wishlist already answers the question those filters approximate. They still respect the active/hidden/entered checks. + +DLC exception (same reasoning): SteamGifts' DLC listing only shows DLC for +games the user owns, so ``is_dlc`` giveaways bypass the same filters when +``dlc_priority`` is on (driven by the dlc_priority_enabled setting). """ from dataclasses import dataclass @@ -67,6 +71,7 @@ class EligibilityCriteria: min_reviews: int | None = None max_game_age: int | None = None wishlist_priority: bool = True + dlc_priority: bool = False @property def needs_game_data(self) -> bool: @@ -107,6 +112,11 @@ def evaluate_eligibility(giveaway: Any, game: Any, criteria: EligibilityCriteria if criteria.wishlist_priority and giveaway.is_wishlist: return ELIGIBLE + # DLC listings are for games the user owns — same bypass, gated by the + # dlc_priority_enabled setting. + if criteria.dlc_priority and giveaway.is_dlc: + return ELIGIBLE + # Price range — matches `price >= min_price [AND price <= max_price]`. if giveaway.price < criteria.min_price: return PRICE_BELOW_MIN diff --git a/backend/src/services/giveaway_query.py b/backend/src/services/giveaway_query.py index 7d924f3..54fb7b8 100644 --- a/backend/src/services/giveaway_query.py +++ b/backend/src/services/giveaway_query.py @@ -62,6 +62,7 @@ async def get_eligible_giveaways( max_game_age: int | None = None, limit: int = 50, wishlist_priority: bool = True, + dlc_priority: bool = False, ) -> list[Giveaway]: """ Get eligible giveaways based on criteria. @@ -101,6 +102,7 @@ async def get_eligible_giveaways( max_game_age=max_game_age, limit=limit, wishlist_priority=wishlist_priority, + dlc_priority=dlc_priority, ) return giveaways @@ -149,9 +151,12 @@ async def evaluate_and_get_eligible( logger.info("eligibility_evaluated", total=len(candidates), **dict(counts)) # candidates are ordered by price desc; the stable sort moves wishlist - # giveaways to the front while keeping price order within each group. - if criteria.wishlist_priority: - eligible.sort(key=lambda g: not g.is_wishlist) + # (then DLC) giveaways to the front while keeping price order within + # each group. + eligible.sort(key=lambda g: ( + not (criteria.wishlist_priority and g.is_wishlist), + not (criteria.dlc_priority and g.is_dlc), + )) return eligible[:limit] if limit else eligible async def get_active_giveaways( diff --git a/backend/src/services/giveaway_sync.py b/backend/src/services/giveaway_sync.py index 3386419..5abb46c 100644 --- a/backend/src/services/giveaway_sync.py +++ b/backend/src/services/giveaway_sync.py @@ -132,13 +132,19 @@ async def sync_giveaways( scan_complete = True break - # A complete wishlist scan is the source of truth for the wishlist - # flag: clear it on active giveaways that no longer appear (the game - # was removed from the Steam wishlist). Never after partial scans. - if giveaway_type == "wishlist" and scan_complete: - cleared = await self.giveaway_repo.unset_wishlist_except(scraped_codes) - if cleared: - logger.info("wishlist_flags_cleared", count=cleared) + # A complete wishlist/DLC scan is the source of truth for its flag: + # clear it on active giveaways that no longer appear (the game was + # removed from the wishlist / is no longer listed as owned-game DLC). + # Never after partial scans. + if scan_complete: + if giveaway_type == "wishlist": + cleared = await self.giveaway_repo.unset_flag_except("is_wishlist", scraped_codes) + if cleared: + logger.info("wishlist_flags_cleared", count=cleared) + elif dlc_only: + cleared = await self.giveaway_repo.unset_flag_except("is_dlc", scraped_codes) + if cleared: + logger.info("dlc_flags_cleared", count=cleared) await self.session.commit() return new_count, updated_count @@ -278,6 +284,7 @@ async def _create_giveaway(self, ga_data: dict) -> Giveaway: end_time=ga_data.get("end_time"), game_id=ga_data.get("game_id"), is_wishlist=ga_data.get("is_wishlist", False), + is_dlc=ga_data.get("is_dlc", False), is_entered=ga_data.get("is_entered", False), ) return giveaway @@ -301,8 +308,10 @@ async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict) -> None: if ga_data.get("game_id") and not giveaway.game_id: giveaway.game_id = ga_data["game_id"] - # Set the wishlist flag when this row came from a wishlist scan. + # Set the wishlist/DLC flags when this row came from those scans. # The reverse direction (un-sticking removed games) is handled by - # unset_wishlist_except after a complete wishlist scan. + # unset_flag_except after a complete scan of the same type. if ga_data.get("is_wishlist"): giveaway.is_wishlist = True + if ga_data.get("is_dlc"): + giveaway.is_dlc = True diff --git a/backend/src/services/settings_service.py b/backend/src/services/settings_service.py index b7a901d..ef1dff7 100644 --- a/backend/src/services/settings_service.py +++ b/backend/src/services/settings_service.py @@ -250,7 +250,7 @@ async def reset_to_defaults(self) -> Settings: user_agent=user_agent, xsrf_token=xsrf_token, # Reset DLC settings - dlc_enabled=False, + dlc_priority_enabled=False, # Reset autojoin settings autojoin_enabled=False, autojoin_start_at=350, # Integer default (point threshold) diff --git a/backend/src/utils/steamgifts_client.py b/backend/src/utils/steamgifts_client.py index 8d41886..8c19b4e 100644 --- a/backend/src/utils/steamgifts_client.py +++ b/backend/src/utils/steamgifts_client.py @@ -451,7 +451,9 @@ async def get_giveaways( ) giveaways = parser.parse_giveaway_list( - response.text, mark_wishlist=giveaway_type == "wishlist" + response.text, + mark_wishlist=giveaway_type == "wishlist", + mark_dlc=dlc_only, ) # Zero rows without the explicit "No results were found." marker means diff --git a/backend/src/utils/steamgifts_parser.py b/backend/src/utils/steamgifts_parser.py index 735fa56..49c07ac 100644 --- a/backend/src/utils/steamgifts_parser.py +++ b/backend/src/utils/steamgifts_parser.py @@ -118,11 +118,15 @@ def parse_username(html: str) -> str | None: return None -def parse_giveaway_list(html: str, mark_wishlist: bool = False) -> list[dict[str, Any]]: +def parse_giveaway_list( + html: str, mark_wishlist: bool = False, mark_dlc: bool = False +) -> list[dict[str, Any]]: """Parse a giveaway search/listing page into giveaway dicts. Skips pinned/"Featured" advertisement giveaways. Rows that fail to parse are logged and dropped rather than failing the whole page. + ``mark_wishlist``/``mark_dlc`` tag every parsed row with the scan type + it came from (wishlist page, DLC page). """ soup = BeautifulSoup(html, "html.parser") @@ -141,6 +145,7 @@ def parse_giveaway_list(html: str, mark_wishlist: bool = False) -> list[dict[str giveaway = parse_giveaway_element(element) if giveaway: giveaway["is_wishlist"] = mark_wishlist + giveaway["is_dlc"] = mark_dlc giveaways.append(giveaway) except Exception as e: # Log error but continue parsing other giveaways diff --git a/backend/src/workers/automation.py b/backend/src/workers/automation.py index 0f72310..3a9d569 100644 --- a/backend/src/workers/automation.py +++ b/backend/src/workers/automation.py @@ -3,7 +3,7 @@ Single unified job that performs all automated tasks in sequence: 1. Scan regular giveaways 2. Scan wishlist giveaways -3. Scan DLC giveaways (if enabled) +3. Scan DLC giveaways 4. Sync wins 5. Sync entered giveaways 6. Process eligible giveaways (enter them) @@ -30,7 +30,7 @@ async def automation_cycle() -> dict[str, Any]: """ Run a complete automation cycle. - Runs all tasks in sequence: scan regular + wishlist (+ DLC) giveaways, + Runs all tasks in sequence: scan regular + wishlist + DLC giveaways, sync wins, sync entered giveaways, then enter eligible giveaways. Returns: @@ -108,28 +108,26 @@ async def automation_cycle() -> dict[str, Any]: logger.error("scan_wishlist_failed", error=str(e)) results["wishlist"]["error"] = str(e) - # === STEP 2.5: Scan DLC giveaways (if enabled) === - dlc_enabled = getattr(settings, 'dlc_enabled', False) - if dlc_enabled: - logger.info("automation_step", step="scan_dlc") - results["dlc"] = {"new": 0, "updated": 0, "skipped": False} + # === STEP 2.5: Scan DLC giveaways === + # Always scanned, like the wishlist: the is_dlc flags it maintains + # feed both the DLC badge and the optional autojoin priority. + logger.info("automation_step", step="scan_dlc") + results["dlc"] = {"new": 0, "updated": 0, "skipped": False} - try: - dlc_new, dlc_updated = await giveaway_service.sync_giveaways( - pages=1, - dlc_only=True - ) - results["dlc"] = { - "new": dlc_new, - "updated": dlc_updated, - "skipped": False, - } - logger.info("scan_dlc_completed", new=dlc_new, updated=dlc_updated) - except Exception as e: - logger.error("scan_dlc_failed", error=str(e)) - results["dlc"]["error"] = str(e) - else: - results["dlc"] = {"skipped": True, "reason": "dlc_disabled"} + try: + dlc_new, dlc_updated = await giveaway_service.sync_giveaways( + pages=max_pages, + dlc_only=True + ) + results["dlc"] = { + "new": dlc_new, + "updated": dlc_updated, + "skipped": False, + } + logger.info("scan_dlc_completed", new=dlc_new, updated=dlc_updated) + except Exception as e: + logger.error("scan_dlc_failed", error=str(e)) + results["dlc"]["error"] = str(e) # === STEP 3: Sync wins === logger.info("automation_step", step="sync_wins") diff --git a/backend/src/workers/processor.py b/backend/src/workers/processor.py index 85bd479..17f3e43 100644 --- a/backend/src/workers/processor.py +++ b/backend/src/workers/processor.py @@ -136,6 +136,7 @@ async def _process_entries( min_reviews=settings.autojoin_min_reviews, max_game_age=settings.autojoin_max_game_age, wishlist_priority=bool(settings.wishlist_priority_enabled), + dlc_priority=bool(settings.dlc_priority_enabled), ) eligible = await giveaway_service.evaluate_and_get_eligible(criteria, limit=max_entries) @@ -191,7 +192,12 @@ async def _process_entries( logger.debug("entry_delay", delay=delay) await asyncio.sleep(delay) - entry_type = "wishlist" if giveaway.is_wishlist else "auto" + if giveaway.is_wishlist: + entry_type = "wishlist" + elif giveaway.is_dlc: + entry_type = "dlc" + else: + entry_type = "auto" try: entry = await enter(giveaway.code, entry_type=entry_type) diff --git a/backend/tests/unit/test_api_routers_settings.py b/backend/tests/unit/test_api_routers_settings.py index 053a1d5..7d22acf 100644 --- a/backend/tests/unit/test_api_routers_settings.py +++ b/backend/tests/unit/test_api_routers_settings.py @@ -26,7 +26,7 @@ def create_mock_settings(): settings.phpsessid = "test_session" settings.user_agent = "Mozilla/5.0" settings.xsrf_token = None - settings.dlc_enabled = False + settings.dlc_priority_enabled = False settings.autojoin_enabled = True settings.autojoin_start_at = 350 settings.autojoin_stop_at = 200 diff --git a/backend/tests/unit/test_eligibility.py b/backend/tests/unit/test_eligibility.py index 042aaad..81c2f7f 100644 --- a/backend/tests/unit/test_eligibility.py +++ b/backend/tests/unit/test_eligibility.py @@ -46,6 +46,7 @@ def _gw(**kw): is_hidden=False, is_entered=False, is_wishlist=False, + is_dlc=False, price=50, game_id=620, ) @@ -133,6 +134,19 @@ def test_wishlist_bypasses_price_and_game_filters(): assert evaluate_eligibility(_gw(is_wishlist=True, price=1), None, crit, NOW) == ELIGIBLE +def test_dlc_bypasses_filters_when_priority_enabled(): + # DLC listings are for owned games: with dlc_priority on, quality and + # price filters don't apply. + crit = EligibilityCriteria(min_price=100, min_score=9, min_reviews=10000, dlc_priority=True) + assert evaluate_eligibility(_gw(is_dlc=True, price=1), None, crit, NOW) == ELIGIBLE + + +def test_dlc_no_bypass_by_default(): + # dlc_priority defaults to off (dlc_priority_enabled setting drives it). + crit = EligibilityCriteria(min_price=100) + assert evaluate_eligibility(_gw(is_dlc=True, price=1), None, crit, NOW) == PRICE_BELOW_MIN + + def test_wishlist_no_bypass_when_priority_disabled(): # With the toggle off, wishlist giveaways pass the same filters as # everything else. @@ -191,11 +205,12 @@ async def _seed(session): Game(id=5, name="Unknown", type="dlc", review_score=0, total_reviews=0, release_date=None), ]) - def gw(code, price=50, game_id=1, end=future, hidden=False, entered=False, wishlist=False): + def gw(code, price=50, game_id=1, end=future, hidden=False, entered=False, + wishlist=False, dlc=False): return Giveaway( code=code, url=f"http://x/{code}", game_name=code, price=price, end_time=end, game_id=game_id, is_hidden=hidden, is_entered=entered, - is_wishlist=wishlist, + is_wishlist=wishlist, is_dlc=dlc, ) session.add_all([ @@ -215,6 +230,8 @@ def gw(code, price=50, game_id=1, end=future, hidden=False, entered=False, wishl gw("wishbad", price=1, game_id=2, wishlist=True), # low price + low score → still eligible gw("wishnogame", game_id=None, wishlist=True), # no game data → still eligible gw("wishhidden", game_id=1, hidden=True, wishlist=True), # hidden beats wishlist + # DLC row: bypasses filters only when dlc_priority is on + gw("dlcbad", price=1, game_id=2, dlc=True), # fails price + score filters ]) await session.commit() @@ -235,6 +252,7 @@ def _service(session): dict(min_price=10, max_price=70, min_score=7, min_reviews=1000), # + max price dict(min_price=0, min_score=0, min_reviews=0), # zero thresholds dict(min_price=10, min_score=7, min_reviews=1000, wishlist_priority=False), # toggle off + dict(min_price=10, min_score=7, min_reviews=1000, dlc_priority=True), # DLC priority on ]) @pytest.mark.asyncio async def test_evaluator_matches_sql_get_eligible(test_db, crit_kwargs): @@ -255,6 +273,7 @@ async def test_evaluator_matches_sql_get_eligible(test_db, crit_kwargs): "min_reviews": crit_kwargs.get("min_reviews"), "max_game_age": crit_kwargs.get("max_game_age"), "wishlist_priority": crit_kwargs.get("wishlist_priority", True), + "dlc_priority": crit_kwargs.get("dlc_priority", False), }) evaluated = await service.evaluate_and_get_eligible(criteria) eval_codes = {g.code for g in evaluated} @@ -264,15 +283,27 @@ async def test_evaluator_matches_sql_get_eligible(test_db, crit_kwargs): if criteria.wishlist_priority: # Wishlist rows bypass the filters, so they are always in the set. assert {"wishbad", "wishnogame"} <= eval_codes - # Ordering: wishlist first, then price descending within each group. - keys = [(not g.is_wishlist, -g.price) for g in evaluated] - assert keys == sorted(keys) else: # Toggle off: wishlist rows get no exemption from the filters. assert "wishbad" not in eval_codes # fails price + score filters assert "wishnogame" not in eval_codes # no game data - prices = [g.price for g in evaluated] - assert prices == sorted(prices, reverse=True) + if criteria.dlc_priority: + assert "dlcbad" in eval_codes + elif (crit_kwargs.get("min_score") or 0) > 0: + # Without the priority, strict criteria reject it (price + score). + assert "dlcbad" not in eval_codes + + # Ordering: wishlist first, then DLC, then price desc (active + # priorities only) — mirror the sort key and require sortedness. + keys = [ + ( + not (criteria.wishlist_priority and g.is_wishlist), + not (criteria.dlc_priority and g.is_dlc), + -g.price, + ) + for g in evaluated + ] + assert keys == sorted(keys) # ---------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_models_settings.py b/backend/tests/unit/test_models_settings.py index 743e82a..4994767 100644 --- a/backend/tests/unit/test_models_settings.py +++ b/backend/tests/unit/test_models_settings.py @@ -57,7 +57,7 @@ def test_settings_creation_with_defaults(session): assert settings.phpsessid is None assert settings.user_agent.startswith("Mozilla/5.0") assert settings.xsrf_token is None - assert settings.dlc_enabled is False + assert settings.dlc_priority_enabled is False assert settings.autojoin_enabled is False assert settings.autojoin_start_at == 350 assert settings.autojoin_stop_at == 200 @@ -80,7 +80,7 @@ def test_settings_with_custom_values(session): settings = Settings( id=1, phpsessid="test_session_id", - dlc_enabled=True, + dlc_priority_enabled=True, autojoin_enabled=True, autojoin_start_at=400, ) @@ -88,7 +88,7 @@ def test_settings_with_custom_values(session): session.commit() assert settings.phpsessid == "test_session_id" - assert settings.dlc_enabled is True + assert settings.dlc_priority_enabled is True assert settings.autojoin_enabled is True assert settings.autojoin_start_at == 400 diff --git a/backend/tests/unit/test_repositories_settings.py b/backend/tests/unit/test_repositories_settings.py index 6005eb2..e61059b 100644 --- a/backend/tests/unit/test_repositories_settings.py +++ b/backend/tests/unit/test_repositories_settings.py @@ -73,7 +73,7 @@ async def test_get_settings_creates_if_missing(settings_repo, session): assert settings is not None assert settings.id == 1 assert settings.autojoin_enabled is False # default value - assert settings.dlc_enabled is False # default value + assert settings.dlc_priority_enabled is False # default value @pytest.mark.asyncio diff --git a/backend/tests/unit/test_schemas_settings.py b/backend/tests/unit/test_schemas_settings.py index bf445bd..b181e32 100644 --- a/backend/tests/unit/test_schemas_settings.py +++ b/backend/tests/unit/test_schemas_settings.py @@ -21,7 +21,7 @@ def test_settings_base_defaults(): ) assert settings.phpsessid is None - assert settings.dlc_enabled is False + assert settings.dlc_priority_enabled is False assert settings.autojoin_enabled is False assert settings.autojoin_start_at == 350 assert settings.autojoin_stop_at == 200 @@ -106,7 +106,7 @@ def test_settings_response_from_dict(): "id": 1, "user_agent": "Mozilla/5.0", "phpsessid": "abc123", - "dlc_enabled": True, + "dlc_priority_enabled": True, "autojoin_enabled": True, "autojoin_start_at": 350, "autojoin_stop_at": 200, diff --git a/backend/tests/unit/test_services_giveaway_service.py b/backend/tests/unit/test_services_giveaway_service.py index 160ed3a..7c033ce 100644 --- a/backend/tests/unit/test_services_giveaway_service.py +++ b/backend/tests/unit/test_services_giveaway_service.py @@ -263,6 +263,30 @@ async def test_sync_wishlist_no_unstick_after_partial_scan(test_db, mock_sg_clie assert (await service.giveaway_repo.get_by_code("GONE1")).is_wishlist is True +@pytest.mark.asyncio +async def test_sync_dlc_flags_and_unsticks(test_db, mock_sg_client, mock_game_service): + """A complete DLC scan sets is_dlc on scraped rows and clears it on + active giveaways that no longer appear in the DLC listing.""" + async with test_db() as session: + service = GiveawayService(session, mock_sg_client, mock_game_service) + + await service.giveaway_repo.create( + code="OLDLC", url="http://x/OLDLC", game_name="Formerly DLC", + price=10, end_time=utcnow() + timedelta(days=1), is_dlc=True, + ) + await session.commit() + + mock_sg_client.get_giveaways = AsyncMock(return_value=[{ + "code": "NEWDL", "game_name": "Fresh DLC", "price": 5, + "end_time": utcnow() + timedelta(days=1), "is_dlc": True, + }]) + + await service.sync_giveaways(pages=3, dlc_only=True) + + assert (await service.giveaway_repo.get_by_code("NEWDL")).is_dlc is True + assert (await service.giveaway_repo.get_by_code("OLDLC")).is_dlc is False + + @pytest.mark.asyncio async def test_sync_drift_logs_activity_and_skips_unstick(test_db, mock_sg_client, mock_game_service): """Scrape drift aborts the scan, writes a warning to the activity log and diff --git a/backend/tests/unit/test_workers_automation.py b/backend/tests/unit/test_workers_automation.py index fff4071..91f5b1a 100644 --- a/backend/tests/unit/test_workers_automation.py +++ b/backend/tests/unit/test_workers_automation.py @@ -12,7 +12,7 @@ def _cycle_settings(**overrides): s = MagicMock() s.phpsessid = "test_session" s.max_scan_pages = 3 - s.dlc_enabled = False + s.dlc_priority_enabled = False s.autojoin_enabled = True s.autojoin_min_price = 50 s.autojoin_min_score = 7 @@ -58,6 +58,7 @@ async def test_automation_cycle_runs_all_steps_and_schedules_win_check(): mock_giveaway.price = 50 mock_giveaway.game_name = "Test Game" mock_giveaway.is_wishlist = False + mock_giveaway.is_dlc = False mock_entry = MagicMock() mock_entry.points_spent = 50 diff --git a/backend/tests/unit/test_workers_processor.py b/backend/tests/unit/test_workers_processor.py index 42c8e4b..e0bb424 100644 --- a/backend/tests/unit/test_workers_processor.py +++ b/backend/tests/unit/test_workers_processor.py @@ -19,6 +19,7 @@ def _autojoin_settings(**overrides): mock_settings.autojoin_start_at = 0 mock_settings.autojoin_stop_at = 0 mock_settings.wishlist_priority_enabled = True + mock_settings.dlc_priority_enabled = False mock_settings.max_entries_per_cycle = 5 mock_settings.entry_delay_min = 0.01 mock_settings.entry_delay_max = 0.02 @@ -28,13 +29,15 @@ def _autojoin_settings(**overrides): return mock_settings -def _mock_giveaway(code="TEST123", price=50, game_name="Test Game", is_wishlist=False): +def _mock_giveaway(code="TEST123", price=50, game_name="Test Game", + is_wishlist=False, is_dlc=False): """Build a giveaway mock with the fields the processor touches.""" mock_giveaway = MagicMock() mock_giveaway.code = code mock_giveaway.price = price mock_giveaway.game_name = game_name mock_giveaway.is_wishlist = is_wishlist + mock_giveaway.is_dlc = is_dlc return mock_giveaway @@ -263,6 +266,40 @@ async def test_process_giveaways_wishlist_entry_type(): ) +@pytest.mark.asyncio +async def test_process_giveaways_dlc_entry_type(): + """DLC giveaways are recorded with entry_type='dlc' (wishlist wins ties).""" + from workers.processor import process_giveaways + + mock_settings = _autojoin_settings(dlc_priority_enabled=True) + + dlc_ga = _mock_giveaway(code="DLC01", is_dlc=True) + both_ga = _mock_giveaway(code="BOTH1", is_wishlist=True, is_dlc=True) + + mock_entry = MagicMock() + mock_entry.points_spent = 50 + + mock_giveaway_service = AsyncMock() + mock_giveaway_service.get_current_points.return_value = 500 + mock_giveaway_service.evaluate_and_get_eligible.return_value = [both_ga, dlc_ga] + mock_giveaway_service.enter_giveaway.return_value = mock_entry + + patcher, ctx = patch_automation_context( + "workers.processor", mock_settings, giveaway_service=mock_giveaway_service + ) + + with patcher, \ + patch("workers.processor.event_manager") as mock_event_manager, \ + patch("workers.processor.asyncio.sleep", new_callable=AsyncMock): + mock_event_manager.broadcast_event = AsyncMock() + + results = await process_giveaways() + + assert results["entered"] == 2 + mock_giveaway_service.enter_giveaway.assert_any_await("BOTH1", entry_type="wishlist") + mock_giveaway_service.enter_giveaway.assert_any_await("DLC01", entry_type="dlc") + + @pytest.mark.asyncio async def test_process_giveaways_skipped_below_start_threshold(): """No entries are attempted while the balance is below autojoin_start_at.""" diff --git a/frontend/e2e/mocks.ts b/frontend/e2e/mocks.ts index 4f8a4c0..62dc5c7 100644 --- a/frontend/e2e/mocks.ts +++ b/frontend/e2e/mocks.ts @@ -17,7 +17,7 @@ export const mockSettings = { phpsessid: 'e2e-session-cookie', user_agent: 'SteamSelfGifter/2.0', xsrf_token: null, - dlc_enabled: false, + dlc_priority_enabled: false, safety_check_enabled: true, auto_hide_unsafe: true, autojoin_enabled: true, @@ -74,6 +74,7 @@ function giveaway(id: number, over: Record = {}) { is_hidden: false, is_entered: false, is_wishlist: false, + is_dlc: false, is_won: false, won_at: null, is_safe: true, diff --git a/frontend/src/hooks/useGiveaways.test.tsx b/frontend/src/hooks/useGiveaways.test.tsx index f10d8a4..0ce6606 100644 --- a/frontend/src/hooks/useGiveaways.test.tsx +++ b/frontend/src/hooks/useGiveaways.test.tsx @@ -66,6 +66,7 @@ const mockGiveaway: Giveaway = { is_hidden: false, is_entered: false, is_wishlist: false, + is_dlc: false, is_won: false, won_at: null, is_safe: true, diff --git a/frontend/src/hooks/useSettings.test.tsx b/frontend/src/hooks/useSettings.test.tsx index bcb26cb..c8a190a 100644 --- a/frontend/src/hooks/useSettings.test.tsx +++ b/frontend/src/hooks/useSettings.test.tsx @@ -49,7 +49,7 @@ const mockSettings: Settings = { phpsessid: 'test-session-id', user_agent: 'test-user-agent', xsrf_token: 'test-token', - dlc_enabled: true, + dlc_priority_enabled: true, safety_check_enabled: true, auto_hide_unsafe: true, autojoin_enabled: true, @@ -116,7 +116,7 @@ describe('useSettings', () => { describe('useUpdateSettings hook', () => { it('should update settings successfully', async () => { - const updatedSettings = { ...mockSettings, dlc_enabled: false }; + const updatedSettings = { ...mockSettings, dlc_priority_enabled: false }; mockApi.put.mockResolvedValueOnce({ success: true, @@ -127,14 +127,14 @@ describe('useSettings', () => { wrapper: createWrapper(), }); - result.current.mutate({ dlc_enabled: false }); + result.current.mutate({ dlc_priority_enabled: false }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toEqual(updatedSettings); - expect(mockApi.put).toHaveBeenCalledWith('/api/v1/settings', { dlc_enabled: false }); + expect(mockApi.put).toHaveBeenCalledWith('/api/v1/settings', { dlc_priority_enabled: false }); }); it('should handle update error', async () => { @@ -148,7 +148,7 @@ describe('useSettings', () => { wrapper: createWrapper(), }); - result.current.mutate({ dlc_enabled: false }); + result.current.mutate({ dlc_priority_enabled: false }); await waitFor(() => { expect(result.current.isError).toBe(true); diff --git a/frontend/src/pages/Giveaways.tsx b/frontend/src/pages/Giveaways.tsx index 77420fc..b87294e 100644 --- a/frontend/src/pages/Giveaways.tsx +++ b/frontend/src/pages/Giveaways.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { ExternalLink, Eye, EyeOff, Gift, Clock, AlertCircle, Loader2, X, Heart, Trophy, Star, Shield, ShieldAlert, EyeOff as HideIcon, MessageSquare, Percent, Users } from 'lucide-react'; +import { ExternalLink, Eye, EyeOff, Gift, Clock, AlertCircle, Loader2, X, Heart, Trophy, Star, Shield, ShieldAlert, EyeOff as HideIcon, MessageSquare, Percent, Users, Package } from 'lucide-react'; import { SiSteam } from 'react-icons/si'; import { Card, Button, Badge, Input, CardSkeleton } from '@/components/common'; import { useInfiniteGiveaways, useEnterGiveaway, useHideGiveaway, useUnhideGiveaway, useRemoveEntry, useCheckGiveawaySafety, useHideOnSteamGifts, usePostComment, type GiveawayFilters } from '@/hooks'; @@ -498,6 +498,12 @@ function GiveawayCard({ giveaway, onEnter, onHide, onUnhide, onRemoveEntry, onCh Wishlist )} + {giveaway.is_dlc && ( + + + DLC + + )} {giveaway.is_entered && !giveaway.is_won && Entered} {giveaway.is_hidden && Hidden} {isExpired && !giveaway.is_won && Expired} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 911162b..021c86d 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -274,6 +274,7 @@ function EntryCard({ entry }: EntryCardProps) { auto: 'Automatic', manual: 'Manual', wishlist: 'Wishlist', + dlc: 'DLC', }; const config = statusConfig[entry.status]; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2f8a684..e23dd37 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -47,7 +47,7 @@ function SettingsForm({ settings }: { settings: SettingsType }) { const [formData, setFormData] = useState>(() => ({ phpsessid: settings.phpsessid ?? '', user_agent: settings.user_agent, - dlc_enabled: settings.dlc_enabled, + dlc_priority_enabled: settings.dlc_priority_enabled, safety_check_enabled: settings.safety_check_enabled, auto_hide_unsafe: settings.auto_hide_unsafe, autojoin_enabled: settings.autojoin_enabled, @@ -199,12 +199,6 @@ function SettingsForm({ settings }: { settings: SettingsType }) { checked={formData.autojoin_enabled ?? false} onChange={(checked) => handleChange('autojoin_enabled', checked)} /> - handleChange('dlc_enabled', checked)} - /> @@ -240,6 +234,14 @@ function SettingsForm({ settings }: { settings: SettingsType }) { onChange={(checked) => handleChange('wishlist_priority_enabled', checked)} /> +
+ handleChange('dlc_priority_enabled', checked)} + /> +