Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions backend/src/api/routers/giveaways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
17 changes: 11 additions & 6 deletions backend/src/repositories/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
26 changes: 19 additions & 7 deletions backend/src/workers/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
25 changes: 17 additions & 8 deletions backend/tests/unit/test_repositories_giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/unit/test_workers_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ 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

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()

Expand Down Expand Up @@ -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(
Expand All @@ -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


Expand Down
3 changes: 3 additions & 0 deletions frontend/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/],
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ function AppContent() {

{/* Main pages */}
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/giveaways" element={<Giveaways />} />
<Route path="/giveaways" element={<Giveaways status="active" />} />
<Route path="/giveaways/wishlist" element={<Giveaways status="wishlist" />} />
<Route path="/giveaways/dlc" element={<Giveaways status="dlc" />} />
<Route path="/giveaways/entered" element={<Giveaways status="entered" />} />
<Route path="/wins" element={<Wins />} />
<Route path="/history" element={<History />} />
<Route path="/analytics" element={<Analytics />} />
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/layout/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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');
Expand All @@ -34,6 +40,6 @@ describe('Sidebar', () => {
expect(nav).toBeInTheDocument();

const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(7);
expect(listItems).toHaveLength(10);
});
});
14 changes: 12 additions & 2 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { NavLink } from 'react-router';
import {
LayoutDashboard,
Gift,
Heart,
Package,
Ticket,
Trophy,
History,
BarChart3,
Expand All @@ -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 },
Expand All @@ -36,10 +45,11 @@ export function Sidebar() {
<aside className="w-64 border-r border-gray-200 dark:border-gray-700 bg-surface-light dark:bg-surface-dark min-h-[calc(100vh-4rem)]">
<nav className="p-4">
<ul className="space-y-2">
{navItems.map(({ path, label, icon: Icon }) => (
{navItems.map(({ path, label, icon: Icon, end }) => (
<li key={path}>
<NavLink
to={path}
end={end}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-2 rounded-lg transition-colors',
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/useGiveaways.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const giveawayKeys = {
* Filter options for giveaways
*/
export interface GiveawayFilters {
status?: 'active' | 'entered' | 'wishlist' | 'won';
status?: 'active' | 'entered' | 'wishlist' | 'dlc' | 'won';
type?: 'game' | 'dlc' | 'bundle' | 'all';
search?: string;
sort?: 'end_time' | 'price' | 'discovered_at';
Expand Down Expand Up @@ -67,6 +67,8 @@ function buildGiveawaysEndpoint(
endpointPath = '/api/v1/giveaways/active';
} else if (filters.status === 'wishlist') {
endpointPath = '/api/v1/giveaways/wishlist';
} else if (filters.status === 'dlc') {
endpointPath = '/api/v1/giveaways/dlc';
} else if (filters.status === 'won') {
endpointPath = '/api/v1/giveaways/won';
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ export function Dashboard() {
))}
{(enteredData.total ?? 0) > 10 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
<a href="/giveaways?status=entered" className="text-primary-light hover:underline">
<a href="/giveaways/entered" className="text-primary-light hover:underline">
View all {enteredData.total} entered giveaways →
</a>
</p>
Expand Down
Loading
Loading