diff --git a/backend/src/api/routers/giveaways.py b/backend/src/api/routers/giveaways.py index 0b0adb3..af23568 100644 --- a/backend/src/api/routers/giveaways.py +++ b/backend/src/api/routers/giveaways.py @@ -153,8 +153,48 @@ async def get_wishlist_giveaways( Returns: Success response with list of wishlist giveaways """ - giveaways = await giveaway_service.giveaway_repo.get_wishlist( - limit=limit, offset=offset, + giveaways = await giveaway_service.giveaway_repo.get_flagged( + "is_wishlist", limit=limit, offset=offset, + min_chance=min_chance, ending_within_minutes=ending_within, + ) + + # Enrich with game data (thumbnails, reviews) + giveaways = await giveaway_service.enrich_giveaways_with_game_data(giveaways) + + giveaway_list = [ + GiveawayResponse.model_validate(g).model_dump() + for g in giveaways + ] + + return create_success_response( + data={ + "giveaways": giveaway_list, + "count": len(giveaway_list), + } + ) + + +@router.get( + "/dlc", + response_model=dict[str, Any], + summary="Get DLC giveaways", + description="Get active giveaways from the DLC listing (DLC for games the user owns).", +) +async def get_dlc_giveaways( + giveaway_service: GiveawayServiceDep, + min_chance: float | None = Query(default=None, ge=0.01, le=100, description="Minimum win chance in percent"), + ending_within: int | None = Query(default=None, ge=1, description="Only giveaways ending within this many minutes"), + limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), + offset: int = Query(default=0, ge=0, description="Number of records to skip"), +) -> dict[str, Any]: + """ + Get DLC giveaways. + + Returns: + Success response with list of DLC giveaways + """ + giveaways = await giveaway_service.giveaway_repo.get_flagged( + "is_dlc", limit=limit, offset=offset, min_chance=min_chance, ending_within_minutes=ending_within, ) diff --git a/backend/src/repositories/giveaway.py b/backend/src/repositories/giveaway.py index ee3a199..8345ccb 100644 --- a/backend/src/repositories/giveaway.py +++ b/backend/src/repositories/giveaway.py @@ -405,30 +405,35 @@ async def get_entered( result = await self.session.execute(query) return list(result.scalars().all()) - async def get_wishlist( - self, limit: int | None = None, offset: int | None = None, + async def get_flagged( + self, flag: str, limit: int | None = None, offset: int | None = None, min_chance: float | None = None, ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ - Get active wishlist giveaways. + Get active giveaways carrying a scan-derived flag. Args: + flag: Column name, "is_wishlist" or "is_dlc" limit: Maximum number to return offset: Number of records to skip min_chance: Minimum win chance in percent (copies/entries*100) ending_within_minutes: Only giveaways ending within this many minutes Returns: - List of wishlist giveaways that are still active (not expired) + List of flagged giveaways that are still active (not expired) Example: - >>> wishlist = await repo.get_wishlist(limit=20) + >>> wishlist = await repo.get_flagged("is_wishlist", limit=20) """ + if flag not in ("is_wishlist", "is_dlc"): + raise ValueError(f"Unsupported scan flag: {flag}") + column = getattr(self.model, flag) + now = utcnow() query = ( select(self.model) .where( - self.model.is_wishlist == True, # noqa: E712 + column == True, # noqa: E712 self.model.is_hidden == False, # noqa: E712 (self.model.end_time == None) | (self.model.end_time > now), # noqa: E711 *self._browse_filter_conditions(now, min_chance, ending_within_minutes), diff --git a/backend/src/workers/scanner.py b/backend/src/workers/scanner.py index 361290e..860babc 100644 --- a/backend/src/workers/scanner.py +++ b/backend/src/workers/scanner.py @@ -32,8 +32,8 @@ async def scan_giveaways() -> dict[str, Any]: """ Scan SteamGifts for giveaways and sync to database (manual trigger). - Scans ``max_scan_pages`` regular pages plus one wishlist page, and emits - a ``scan_completed`` event. + Scans ``max_scan_pages`` regular pages plus the wishlist and DLC + listings, and emits a ``scan_completed`` event. Returns: Dictionary with scan results (new/updated/pages_scanned/scan_time). @@ -59,11 +59,11 @@ async def scan_giveaways() -> dict[str, Any]: pages=max_pages ) - # Also scan wishlist giveaways so the Wishlist tab is populated by - # manual scans too. Uses the same page cap as the regular scan; - # the sync stops early at the end of the list, so small wishlists - # cost a single request. A wishlist failure shouldn't fail the - # whole scan. + # Also scan the wishlist and DLC listings so their pages are + # populated by manual scans too. Both use the same page cap as + # the regular scan; the sync stops early at the end of the list, + # so small listings cost a single request. A failure in either + # shouldn't fail the whole scan. wishlist_new = wishlist_updated = 0 try: wishlist_new, wishlist_updated = await ctx.giveaway_service.sync_giveaways( @@ -72,12 +72,22 @@ async def scan_giveaways() -> dict[str, Any]: except Exception as e: logger.error("scan_wishlist_failed", error=str(e)) + dlc_new = dlc_updated = 0 + try: + dlc_new, dlc_updated = await ctx.giveaway_service.sync_giveaways( + pages=max_pages, dlc_only=True + ) + except Exception as e: + logger.error("scan_dlc_failed", error=str(e)) + scan_time = (datetime.now(UTC) - start_time).total_seconds() results = { "new": new_count, "updated": updated_count, "wishlist_new": wishlist_new, "wishlist_updated": wishlist_updated, + "dlc_new": dlc_new, + "dlc_updated": dlc_updated, "pages_scanned": max_pages, "scan_time": round(scan_time, 2), "skipped": False, @@ -94,6 +104,8 @@ async def scan_giveaways() -> dict[str, Any]: updated=updated_count, wishlist_new=wishlist_new, wishlist_updated=wishlist_updated, + dlc_new=dlc_new, + dlc_updated=dlc_updated, pages=max_pages, scan_time=scan_time, ) diff --git a/backend/tests/unit/test_repositories_giveaway.py b/backend/tests/unit/test_repositories_giveaway.py index acfb88e..8a65cec 100644 --- a/backend/tests/unit/test_repositories_giveaway.py +++ b/backend/tests/unit/test_repositories_giveaway.py @@ -139,33 +139,42 @@ async def test_get_active_ending_within_filter(test_db): assert codes == {"SOON", "LATER"} +@pytest.mark.parametrize("flag", ["is_wishlist", "is_dlc"]) @pytest.mark.asyncio -async def test_get_wishlist_browse_filters(test_db): - """The wishlist listing honors min_chance and ending_within_minutes.""" +async def test_get_flagged_browse_filters(test_db, flag): + """The flagged listings honor min_chance and ending_within_minutes.""" async with test_db() as session: repo = GiveawayRepository(session) now = utcnow() await repo.create(code="WSOON", game_name="A", price=10, url="http://x/1", - end_time=now + timedelta(hours=2), is_wishlist=True, + end_time=now + timedelta(hours=2), **{flag: True}, copies=1, entries=10) # 10% await repo.create(code="WCROWD", game_name="B", price=10, url="http://x/2", - end_time=now + timedelta(hours=2), is_wishlist=True, + end_time=now + timedelta(hours=2), **{flag: True}, copies=1, entries=5000) # 0.02% await repo.create(code="WLATER", game_name="C", price=10, url="http://x/3", - end_time=now + timedelta(hours=48), is_wishlist=True, + end_time=now + timedelta(hours=48), **{flag: True}, + copies=1, entries=10) + # Flagged with the other flag only: must never show up. + other = "is_dlc" if flag == "is_wishlist" else "is_wishlist" + await repo.create(code="OTHER", game_name="D", price=10, url="http://x/4", + end_time=now + timedelta(hours=2), **{other: True}, copies=1, entries=10) await session.commit() - codes = {g.code for g in await repo.get_wishlist(min_chance=1.0)} + codes = {g.code for g in await repo.get_flagged(flag, min_chance=1.0)} assert codes == {"WSOON", "WLATER"} - codes = {g.code for g in await repo.get_wishlist(ending_within_minutes=360)} + codes = {g.code for g in await repo.get_flagged(flag, ending_within_minutes=360)} assert codes == {"WSOON", "WCROWD"} - codes = {g.code for g in await repo.get_wishlist(min_chance=1.0, ending_within_minutes=360)} + codes = {g.code for g in await repo.get_flagged(flag, min_chance=1.0, ending_within_minutes=360)} assert codes == {"WSOON"} + with pytest.raises(ValueError): + await repo.get_flagged("is_hidden") + @pytest.mark.asyncio async def test_get_active_excludes_hidden(test_db): diff --git a/backend/tests/unit/test_workers_scanner.py b/backend/tests/unit/test_workers_scanner.py index 247644e..67241ec 100644 --- a/backend/tests/unit/test_workers_scanner.py +++ b/backend/tests/unit/test_workers_scanner.py @@ -32,6 +32,8 @@ async def test_scan_giveaways_success(): assert results["updated"] == 2 assert results["wishlist_new"] == 5 assert results["wishlist_updated"] == 2 + assert results["dlc_new"] == 5 + assert results["dlc_updated"] == 2 assert results["pages_scanned"] == 3 assert results["skipped"] is False assert "scan_time" in results @@ -39,6 +41,7 @@ async def test_scan_giveaways_success(): mock_giveaway_service.sync_giveaways.assert_has_calls([ call(pages=3), call(pages=3, giveaway_type="wishlist"), + call(pages=3, dlc_only=True), ]) mock_event_manager.broadcast_event.assert_called_once() @@ -100,6 +103,7 @@ async def test_scan_giveaways_wishlist_failure_does_not_fail_scan(): mock_giveaway_service.sync_giveaways.side_effect = [ (5, 2), # regular scan Exception("wishlist page error"), # wishlist scan + (3, 1), # DLC scan ] patcher, ctx = patch_automation_context( @@ -115,6 +119,42 @@ async def test_scan_giveaways_wishlist_failure_does_not_fail_scan(): assert results["updated"] == 2 assert results["wishlist_new"] == 0 assert results["wishlist_updated"] == 0 + # The DLC scan still runs after a wishlist failure. + assert results["dlc_new"] == 3 + assert results["dlc_updated"] == 1 + assert results["skipped"] is False + + +@pytest.mark.asyncio +async def test_scan_giveaways_dlc_failure_does_not_fail_scan(): + """A DLC scan error is logged but the rest of the scan still succeeds.""" + from workers.scanner import scan_giveaways + + mock_settings = MagicMock() + mock_settings.phpsessid = "test_session" + mock_settings.max_scan_pages = 3 + + mock_giveaway_service = AsyncMock() + mock_giveaway_service.sync_giveaways.side_effect = [ + (5, 2), # regular scan + (1, 0), # wishlist scan + Exception("dlc page error"), # DLC scan + ] + + patcher, ctx = patch_automation_context( + "workers.scanner", mock_settings, giveaway_service=mock_giveaway_service + ) + + with patcher, patch("workers.scanner.event_manager") as mock_event_manager: + mock_event_manager.broadcast_event = AsyncMock() + + results = await scan_giveaways() + + assert results["new"] == 5 + assert results["updated"] == 2 + assert results["wishlist_new"] == 1 + assert results["dlc_new"] == 0 + assert results["dlc_updated"] == 0 assert results["skipped"] is False diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index 5f3dd54..2100234 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -10,6 +10,9 @@ test.describe('Navigation', () => { test('sidebar links reach every page', async ({ page }) => { const pages: Array<[RegExp | string, RegExp]> = [ ['Giveaways', /\/giveaways$/], + ['Wishlist', /\/giveaways\/wishlist$/], + ['DLC', /\/giveaways\/dlc$/], + ['Entered', /\/giveaways\/entered$/], ['Wins', /\/wins$/], ['History', /\/history$/], ['Analytics', /\/analytics$/], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 930ec2a..92f6ffc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,7 +45,10 @@ function AppContent() { {/* Main pages */} } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx index f6fadf1..5c486d1 100644 --- a/frontend/src/components/layout/Sidebar.test.tsx +++ b/frontend/src/components/layout/Sidebar.test.tsx @@ -8,6 +8,9 @@ describe('Sidebar', () => { expect(screen.getByText('Dashboard')).toBeInTheDocument(); expect(screen.getByText('Giveaways')).toBeInTheDocument(); + expect(screen.getByText('Wishlist')).toBeInTheDocument(); + expect(screen.getByText('DLC')).toBeInTheDocument(); + expect(screen.getByText('Entered')).toBeInTheDocument(); expect(screen.getByText('Wins')).toBeInTheDocument(); expect(screen.getByText('History')).toBeInTheDocument(); expect(screen.getByText('Analytics')).toBeInTheDocument(); @@ -20,6 +23,9 @@ describe('Sidebar', () => { expect(screen.getByText('Dashboard').closest('a')).toHaveAttribute('href', '/dashboard'); expect(screen.getByText('Giveaways').closest('a')).toHaveAttribute('href', '/giveaways'); + expect(screen.getByText('Wishlist').closest('a')).toHaveAttribute('href', '/giveaways/wishlist'); + expect(screen.getByText('DLC').closest('a')).toHaveAttribute('href', '/giveaways/dlc'); + expect(screen.getByText('Entered').closest('a')).toHaveAttribute('href', '/giveaways/entered'); expect(screen.getByText('Wins').closest('a')).toHaveAttribute('href', '/wins'); expect(screen.getByText('History').closest('a')).toHaveAttribute('href', '/history'); expect(screen.getByText('Analytics').closest('a')).toHaveAttribute('href', '/analytics'); @@ -34,6 +40,6 @@ describe('Sidebar', () => { expect(nav).toBeInTheDocument(); const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(7); + expect(listItems).toHaveLength(10); }); }); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index bb67c85..d32dd03 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -2,6 +2,9 @@ import { NavLink } from 'react-router'; import { LayoutDashboard, Gift, + Heart, + Package, + Ticket, Trophy, History, BarChart3, @@ -15,12 +18,18 @@ interface NavItem { path: string; label: string; icon: LucideIcon; + // Match the path exactly instead of as a prefix (needed for parent + // routes like /giveaways that have sibling sub-pages). + end?: boolean; } // Navigation items configuration const navItems: NavItem[] = [ { path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, - { path: '/giveaways', label: 'Giveaways', icon: Gift }, + { path: '/giveaways', label: 'Giveaways', icon: Gift, end: true }, + { path: '/giveaways/wishlist', label: 'Wishlist', icon: Heart }, + { path: '/giveaways/dlc', label: 'DLC', icon: Package }, + { path: '/giveaways/entered', label: 'Entered', icon: Ticket }, { path: '/wins', label: 'Wins', icon: Trophy }, { path: '/history', label: 'History', icon: History }, { path: '/analytics', label: 'Analytics', icon: BarChart3 }, @@ -36,10 +45,11 @@ export function Sidebar() {