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
71 changes: 71 additions & 0 deletions src/api/gameClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function gamesCacheKey(limit: number, offset: number): string {
return `games:v1:${limit}:${offset}`
}

function gameByIdCacheKey(appid: number): string {
return `game:v1:${appid}`
}

function readCachedGames(key: string): Game[] | null {
try {
const rawCache = globalThis.localStorage?.getItem(key)
Expand Down Expand Up @@ -74,6 +78,73 @@ function writeCachedGames(key: string, games: Game[]): void {
}
}

function findCachedGameById(appid: number): Game | null {
const cachedGame = readCachedGames(gameByIdCacheKey(appid))?.find(
(game) => game.appid === appid,
)
if (cachedGame) return cachedGame

try {
const storage = globalThis.localStorage
if (!storage) return null

// Collect keys first because readCachedGames may remove expired entries.
const cacheKeys = Array.from({ length: storage.length }, (_, index) =>
storage.key(index),
).filter((key): key is string => key !== null && key.startsWith('games:v1:'))

for (const key of cacheKeys) {
const game = readCachedGames(key)?.find((candidate) => candidate.appid === appid)
if (game) return game
}
} catch (error) {
console.warn('Unable to search localStorage; fetching game from the database.', error)
}

return null
}

export async function getGameById(appid: number): Promise<Game | null> {
const cachedGame = findCachedGameById(appid)
if (cachedGame) return cachedGame

const response = await fetch('http://it2810-10.idi.ntnu.no:3001/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query Game($appid: Int!) {
game(appid: $appid) {
appid
name
price
initialprice
discount
img
genre
description
}
}
`,
variables: { appid },
}),
})

if (!response.ok) {
const body = await response.text()
throw new Error(`Failed to fetch game: ${response.status} ${body}`)
}

const { data, errors } = (await response.json()) as GraphQLResponse<{
game: Game | null
}>
if (errors?.length) throw new Error(errors[0].message)
if (!data) throw new Error('No data returned')

if (data.game) writeCachedGames(gameByIdCacheKey(appid), [data.game])
return data.game
}

async function fetchGamesFromDB(limit: number, offset: number): Promise<Game[]> {
const cacheKey = gamesCacheKey(limit, offset)
const cachedGames = readCachedGames(cacheKey)
Expand Down
23 changes: 21 additions & 2 deletions src/backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ const typeDefs = `#graphql
type Query {
games(limit: Int = 500, offset: Int = 0): [Game!]!
game(appid: Int!): Game
genres: [String!]!
}
`

const resolvers = {
Query: {
games: async (_parent: unknown, { limit, offset }: { limit: number; offset: number }): Promise<Game[]> => {
games: async (
_parent: unknown,
{ limit, offset }: { limit: number; offset: number },
): Promise<Game[]> => {
// This prevents the limit from being too high, and the offset from being negative.
const safeLimit = Math.min(Math.max(limit, 0), 500)
const safeOffset = Math.max(offset, 0)
Expand All @@ -42,10 +46,25 @@ const resolvers = {
FROM games
ORDER BY game_id
LIMIT $1 OFFSET $2`,
[safeLimit, safeOffset]
[safeLimit, safeOffset],
)
return rows
},
game: async (_parent: unknown, { appid }: { appid: number }): Promise<Game | null> => {
const { rows } = await pool.query(
`SELECT game_id AS appid,
game_name AS name,
current_price::text AS price,
initial_price::text AS initialprice,
discount_percent::text AS discount,
image_url AS img,
genre,
description
FROM games
WHERE game_id = $1`,
[appid],
)
return rows[0] ?? null
genres: async (): Promise<string[]> => {
const { rows } = await pool.query(
`SELECT DISTINCT genre
Expand Down
80 changes: 76 additions & 4 deletions src/pages/gamepage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,92 @@ import '../css/gamepage.css'
import Header from '../components/header'
import Footer from '../components/footer'
import ActiveGameCard from '../components/activegamecard'
import { games } from '../api/games'
import { useParams } from 'react-router-dom'
import PageHeading from '../components/pageheading'
import { useEffect, useState } from 'react'
import { getGameById } from '../api/gameClient'
import type { Game } from '../backend/steam'

export default function GamePage() {
const { gameId } = useParams()
const game = gameId ? games.find((candidate) => candidate.id === Number(gameId)) : games[0]
const appid = gameId === undefined ? Number.NaN : Number(gameId)
const [loadedGame, setLoadedGame] = useState<{
appid: number
game: Game | null
error: string | null
} | null>(null)

useEffect(() => {
let cancelled = false

if (!Number.isSafeInteger(appid)) return

getGameById(appid)
.then((loadedGame) => {
if (!cancelled) setLoadedGame({ appid, game: loadedGame, error: null })
})
.catch((err: unknown) => {
if (!cancelled) {
setLoadedGame({
appid,
game: null,
error: err instanceof Error ? err.message : 'Could not load game',
})
}
})

return () => {
cancelled = true
}
}, [appid])

const isValidAppId = Number.isSafeInteger(appid)
const isLoading = isValidAppId && loadedGame?.appid !== appid
const game = loadedGame?.appid === appid ? loadedGame.game : null
const error = loadedGame?.appid === appid ? loadedGame.error : null

const price = game ? Number(game.price) : 0
const salePercent = game ? Number(game.discount) : 0
const genres = game
? game.genre
.split(',')
.map((genre) => genre.trim())
.filter(Boolean)
: []
return (
<div className="page-shell">
<Header />
<PageHeading title={game?.name ?? 'Game not found'} />
<PageHeading
title={
isLoading
? 'Loading game...'
: error
? 'Unable to load game'
: (game?.name ?? 'Game not found')
}
/>
<div className="game-page">
{game ? <ActiveGameCard game={game} /> : <p>Game not found.</p>}
{isLoading ? (
<p>Loading game...</p>
) : error ? (
<p role="alert">Error loading game: {error}</p>
) : game ? (
<ActiveGameCard
key={game.appid}
game={{
id: game.appid,
name: game.name,
price: Number.isFinite(price) ? price : 0,
genre: genres,
developer: 'Unknown developer',
description: game.description,
image: game.img,
salePercent: Number.isFinite(salePercent) ? salePercent : 0,
}}
/>
) : (
<p>Game not found.</p>
)}
</div>
<Footer />
</div>
Expand Down
85 changes: 74 additions & 11 deletions src/tests/gameClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Game } from '../backend/steam'
import fetchGamesFromDB, { getGames } from '../api/gameClient'
import fetchGamesFromDB, { getGameById, getGames } from '../api/gameClient'

function mockResponse(body: unknown, ok = true, status = 200): Response {
return {
Expand All @@ -11,6 +11,18 @@ function mockResponse(body: unknown, ok = true, status = 200): Response {
} as Response
}

function stubLocalStorage(values: Map<string, string>) {
vi.stubGlobal('localStorage', {
get length() {
return values.size
},
key: (index: number) => Array.from(values.keys())[index] ?? null,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
})
}

describe('fetchGamesFromDB', () => {
beforeEach(() => {
vi.restoreAllMocks()
Expand Down Expand Up @@ -95,11 +107,7 @@ describe('fetchGamesFromDB', () => {
},
]
const values = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
})
stubLocalStorage(values)
global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { games } }))

const firstResult = await fetchGamesFromDB(1, 0)
Expand All @@ -124,18 +132,73 @@ describe('fetchGamesFromDB', () => {
}),
],
])
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
})
stubLocalStorage(values)
global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { games: [] } }))

await fetchGamesFromDB(1, 0)

expect(global.fetch).toHaveBeenCalledTimes(1)
})

describe('getGameById', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

const game: Game = {
appid: 10,
name: 'Counter-Strike',
price: '9.99',
initialprice: '9.99',
discount: '0',
img: 'https://example.com/header.jpg',
genre: 'Action',
description: 'A classic shooter.',
}

it('returns a game from an existing page cache without fetching', async () => {
const values = new Map([
[
'games:v1:500:0',
JSON.stringify({ fetchedAt: Date.now(), games: [game] }),
],
])
stubLocalStorage(values)
global.fetch = vi.fn()

await expect(getGameById(game.appid)).resolves.toEqual(game)
expect(global.fetch).not.toHaveBeenCalled()
})

it('fetches a missing game from the database and caches it for subsequent calls', async () => {
const values = new Map<string, string>()
stubLocalStorage(values)
global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { game } }))

await expect(getGameById(game.appid)).resolves.toEqual(game)
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, options] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
const body = JSON.parse(options.body as string)
expect(body.variables).toEqual({ appid: game.appid })
expect(body.query).toContain('game(appid: $appid)')
expect(JSON.parse(values.get('game:v1:10') ?? '{}')).toEqual({
fetchedAt: expect.any(Number),
games: [game],
})

await expect(getGameById(game.appid)).resolves.toEqual(game)
expect(global.fetch).toHaveBeenCalledTimes(1)
})

it('returns null when the requested game does not exist', async () => {
stubLocalStorage(new Map())
global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { game: null } }))

await expect(getGameById(404)).resolves.toBeNull()
})
})

it('throws when the HTTP response is not ok', async () => {
global.fetch = vi.fn().mockResolvedValue(mockResponse({}, false, 500))

Expand Down
Loading