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
8 changes: 4 additions & 4 deletions docs/MOBILE_API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# EmuReady Public Integration API (mobile-compatible tRPC)

*Auto-generated on: 2026-06-15T12:16:27.168Z*
*Auto-generated on: 2026-07-06T20:07:17.160Z*

## Summary
- **Total Endpoints**: 113
Expand All @@ -12,7 +12,7 @@
`/api/mobile/trpc`

## Authentication
Protected endpoints require Bearer token authentication using Clerk JWT.
Protected endpoints require Bearer token authentication using Clerk JWT. Public integration requests can also include an issued API key in `x-api-key`.

## Interactive Documentation
- **Swagger UI**: [/docs/api/swagger](https://emuready.com/docs/api/swagger)
Expand All @@ -33,7 +33,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 2. **getDeviceCompatibility**
- **Method**: GET
- **Path**: `/catalog.getDeviceCompatibility`
- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load.
- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load.
- **Tags**: catalog


Expand Down Expand Up @@ -208,7 +208,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 27. **batchBySteamAppIds**
- **Method**: GET
- **Path**: `/games.batchBySteamAppIds`
- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries
- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries
- **Tags**: games


Expand Down
18 changes: 0 additions & 18 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,24 +280,6 @@ const nextConfig: NextConfig = {
source: '/favicon/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' }],
},
{
source: '/api/mobile/:path*',
headers: [
{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization, x-trpc-source',
},
{ key: 'Access-Control-Expose-Headers', value: 'x-trpc-source' },
],
},
// tRPC endpoints are dynamic; prevent intermediary/proxy caching
{
source: '/api/trpc/:path*',
headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }],
},
{
source: '/(.*)',
headers: [
Expand Down
748 changes: 720 additions & 28 deletions public/api-docs/mobile-openapi.json

Large diffs are not rendered by default.

132 changes: 42 additions & 90 deletions src/app/admin/components/ApprovalCountBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,53 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { api } from '@/lib/api'
import { PERMISSIONS } from '@/utils/permission-system'
import ApprovalCountBadge from './ApprovalCountBadge'

interface UserQueryResult {
data?: {
permissions?: string[] | null
} | null
}

interface StatsQueryResult {
data?: {
pending: number
approved: number
rejected: number
total: number
}
}

const apiMocks = vi.hoisted(() => ({
userMeUseQuery: vi.fn<() => UserQueryResult>(),
gamesStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(),
listingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(),
pcListingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(),
}))

vi.mock('@/lib/api', () => ({
api: {
users: { me: { useQuery: vi.fn() } },
games: { stats: { useQuery: vi.fn() } },
listings: { stats: { useQuery: vi.fn() } },
pcListings: { stats: { useQuery: vi.fn() } },
users: { me: { useQuery: apiMocks.userMeUseQuery } },
games: { stats: { useQuery: apiMocks.gamesStatsUseQuery } },
listings: { stats: { useQuery: apiMocks.listingsStatsUseQuery } },
pcListings: { stats: { useQuery: apiMocks.pcListingsStatsUseQuery } },
},
}))

const mockUserQuery = vi.mocked(api.users.me.useQuery)
const mockGamesStatsQuery = vi.mocked(api.games.stats.useQuery)
const mockListingsStatsQuery = vi.mocked(api.listings.stats.useQuery)
const mockPcListingsStatsQuery = vi.mocked(api.pcListings.stats.useQuery)

describe('ApprovalCountBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('renders badge when count is available and user has permission', () => {
mockUserQuery.mockReturnValue({
apiMocks.userMeUseQuery.mockReturnValue({
data: { permissions: [PERMISSIONS.VIEW_STATISTICS] },
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockGamesStatsQuery.mockReturnValue({
})
apiMocks.gamesStatsUseQuery.mockReturnValue({
data: { pending: 3, approved: 0, rejected: 0, total: 3 },
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockPcListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
})
apiMocks.listingsStatsUseQuery.mockReturnValue({})
apiMocks.pcListingsStatsUseQuery.mockReturnValue({})

render(<ApprovalCountBadge href="/admin/games/approvals" />)

Expand All @@ -60,69 +56,25 @@ describe('ApprovalCountBadge', () => {
})

it('returns null when user lacks permission', () => {
mockUserQuery.mockReturnValue({
apiMocks.userMeUseQuery.mockReturnValue({
data: { permissions: [] },
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockGamesStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockPcListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
})
apiMocks.gamesStatsUseQuery.mockReturnValue({})
apiMocks.listingsStatsUseQuery.mockReturnValue({})
apiMocks.pcListingsStatsUseQuery.mockReturnValue({})

const { container } = render(<ApprovalCountBadge href="/admin/games/approvals" />)

expect(container).toBeEmptyDOMElement()
})

it('returns null for invalid href', () => {
mockUserQuery.mockReturnValue({
apiMocks.userMeUseQuery.mockReturnValue({
data: { permissions: [PERMISSIONS.VIEW_STATISTICS] },
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockGamesStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
mockPcListingsStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
trpc: {},
} as any)
})
apiMocks.gamesStatsUseQuery.mockReturnValue({})
apiMocks.listingsStatsUseQuery.mockReturnValue({})
apiMocks.pcListingsStatsUseQuery.mockReturnValue({})
render(<ApprovalCountBadge href="/admin/unknown" />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
Expand Down
23 changes: 10 additions & 13 deletions src/app/admin/components/ApprovalCountBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { Badge } from '@/components/ui'
import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants'
import { CACHE_DURATIONS } from '@/data/constants'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { hasPermission, PERMISSIONS } from '@/utils/permission-system'
Expand Down Expand Up @@ -30,26 +30,23 @@ export default function ApprovalCountBadge(props: Props) {

const gameStatsQuery = api.games.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/games/approvals',
refetchInterval: POLLING_INTERVALS.SHORT,
staleTime: CACHE_DURATIONS.VERY_SHORT,
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
})

const listingStatsQuery = api.listings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/approvals',
refetchInterval: POLLING_INTERVALS.SHORT,
staleTime: CACHE_DURATIONS.VERY_SHORT,
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
})

const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/pc-listing-approvals',
refetchInterval: POLLING_INTERVALS.SHORT,
staleTime: CACHE_DURATIONS.VERY_SHORT,
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
Comment on lines +33 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove refetchOnMount and refetchOnWindowFocus to allow stale data to refresh.

By setting refetchOnMount: false and refetchOnWindowFocus: false, these queries will never automatically refetch once they have initial data, even after the staleTime has expired. The staleTime: CACHE_DURATIONS.SHORT setting alone is sufficient to prevent unnecessary requests while the data is fresh.

Consider removing these explicit false flags. This allows React Query's default behavior to fetch updated approval counts when a user navigates back to the admin area or refocuses the window after the cache has become stale.

♻️ Proposed fix
   const gameStatsQuery = api.games.stats.useQuery(undefined, {
     enabled: canViewStats && props.href === '/admin/games/approvals',
     staleTime: CACHE_DURATIONS.SHORT,
-    refetchOnMount: false,
-    refetchOnWindowFocus: false,
   })
 
   const listingStatsQuery = api.listings.stats.useQuery(undefined, {
     enabled: canViewStats && props.href === '/admin/approvals',
     staleTime: CACHE_DURATIONS.SHORT,
-    refetchOnMount: false,
-    refetchOnWindowFocus: false,
   })
 
   const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, {
     enabled: canViewStats && props.href === '/admin/pc-listing-approvals',
     staleTime: CACHE_DURATIONS.SHORT,
-    refetchOnMount: false,
-    refetchOnWindowFocus: false,
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const listingStatsQuery = api.listings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/approvals',
refetchInterval: POLLING_INTERVALS.SHORT,
staleTime: CACHE_DURATIONS.VERY_SHORT,
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/pc-listing-approvals',
refetchInterval: POLLING_INTERVALS.SHORT,
staleTime: CACHE_DURATIONS.VERY_SHORT,
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: CACHE_DURATIONS.SHORT,
refetchOnMount: false,
refetchOnWindowFocus: false,
const gameStatsQuery = api.games.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/games/approvals',
staleTime: CACHE_DURATIONS.SHORT,
})
const listingStatsQuery = api.listings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/approvals',
staleTime: CACHE_DURATIONS.SHORT,
})
const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, {
enabled: canViewStats && props.href === '/admin/pc-listing-approvals',
staleTime: CACHE_DURATIONS.SHORT,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/components/ApprovalCountBadge.tsx` around lines 33 - 49, Remove
the explicit refetchOnMount and refetchOnWindowFocus options from both
listingStatsQuery and pcListingStatsQuery in ApprovalCountBadge, preserving
staleTime: CACHE_DURATIONS.SHORT and the existing enabled conditions so React
Query can refresh stale approval counts on mount and window focus.

})

const statsMap = {
Expand Down
10 changes: 2 additions & 8 deletions src/app/admin/custom-field-templates/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CustomFieldType } from '@orm'
import type CustomFieldTemplatesPageComponent from './page'
import CustomFieldTemplatesPage from './page'

const apiMocks = vi.hoisted(() => ({
customFieldTemplatesGetUseQuery: vi.fn(),
Expand Down Expand Up @@ -53,8 +53,6 @@ vi.mock('./components/CustomFieldTemplateFormModal', () => ({
default: () => <div data-testid="template-form-modal" />,
}))

let CustomFieldTemplatesPage: typeof CustomFieldTemplatesPageComponent

const templates = [
{
id: 'template-performance',
Expand Down Expand Up @@ -95,10 +93,6 @@ const templates = [
]

describe('CustomFieldTemplatesPage', () => {
beforeAll(async () => {
;({ default: CustomFieldTemplatesPage } = await import('./page'))
})

beforeEach(() => {
vi.clearAllMocks()
navigationMocks.searchParams = new URLSearchParams()
Expand Down
Loading
Loading