From 5c5a53e6c2f349e99465cb59849bce8133b18de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eline=20J=C3=A5sund?= Date: Wed, 14 Oct 2026 10:52:23 +0200 Subject: [PATCH 1/4] feat: gamepage fetches from database --- src/pages/gamepage.tsx | 61 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/pages/gamepage.tsx b/src/pages/gamepage.tsx index 92b6d01..7e2e4ae 100644 --- a/src/pages/gamepage.tsx +++ b/src/pages/gamepage.tsx @@ -2,20 +2,75 @@ 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 { getGames } from '../api/gameClient' +import type { Game } from '../backend/steam' export default function GamePage() { + const [games, setGames] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + getGames() + .then((loadedGames) => { + if (!cancelled) setGames(loadedGames) + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Could not load games') + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + const { gameId } = useParams() - const game = gameId ? games.find((candidate) => candidate.id === Number(gameId)) : games[0] + const game = gameId ? games.find((candidate) => candidate.appid === Number(gameId)) : games[0] + 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 (
- {game ? :

Game not found.

} + {isLoading ? ( +

Loading game...

+ ) : error ? ( +

Error loading games: {error}

+ ) : game ? ( + + ) : ( +

Game not found.

+ )}
From c46f84df6a164499b3f2bff879850be99cc12a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eline=20J=C3=A5sund?= Date: Wed, 14 Oct 2026 11:35:05 +0200 Subject: [PATCH 2/4] feat: gamepage loads individual game fra database --- src/api/gameClient.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ src/backend/server.ts | 24 ++++++++++++-- src/pages/gamepage.tsx | 49 +++++++++++++++++++---------- 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/api/gameClient.ts b/src/api/gameClient.ts index 349fc4e..fec47f9 100644 --- a/src/api/gameClient.ts +++ b/src/api/gameClient.ts @@ -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) @@ -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 { + 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 { const cacheKey = gamesCacheKey(limit, offset) const cachedGames = readCachedGames(cacheKey) diff --git a/src/backend/server.ts b/src/backend/server.ts index 045e163..a75fa4e 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -20,12 +20,16 @@ const typeDefs = `#graphql type Query { games(limit: Int = 500, offset: Int = 0): [Game!]! + game(appid: Int!): Game } ` const resolvers = { Query: { - games: async (_parent: unknown, { limit, offset }: { limit: number; offset: number }): Promise => { + games: async ( + _parent: unknown, + { limit, offset }: { limit: number; offset: number }, + ): Promise => { // 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) @@ -41,10 +45,26 @@ 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 => { + 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 + }, }, } diff --git a/src/pages/gamepage.tsx b/src/pages/gamepage.tsx index 7e2e4ae..dd0bd4b 100644 --- a/src/pages/gamepage.tsx +++ b/src/pages/gamepage.tsx @@ -5,37 +5,46 @@ import ActiveGameCard from '../components/activegamecard' import { useParams } from 'react-router-dom' import PageHeading from '../components/pageheading' import { useEffect, useState } from 'react' -import { getGames } from '../api/gameClient' +import { getGameById } from '../api/gameClient' import type { Game } from '../backend/steam' export default function GamePage() { - const [games, setGames] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) + const { gameId } = useParams() + 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 - getGames() - .then((loadedGames) => { - if (!cancelled) setGames(loadedGames) + if (!Number.isSafeInteger(appid)) return + + getGameById(appid) + .then((loadedGame) => { + if (!cancelled) setLoadedGame({ appid, game: loadedGame, error: null }) }) .catch((err: unknown) => { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Could not load games') + setLoadedGame({ + appid, + game: null, + error: err instanceof Error ? err.message : 'Could not load game', + }) } }) - .finally(() => { - if (!cancelled) setIsLoading(false) - }) return () => { cancelled = true } - }, []) + }, [appid]) - const { gameId } = useParams() - const game = gameId ? games.find((candidate) => candidate.appid === Number(gameId)) : games[0] + 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 @@ -48,12 +57,20 @@ export default function GamePage() { return (
- +
{isLoading ? (

Loading game...

) : error ? ( -

Error loading games: {error}

+

Error loading game: {error}

) : game ? ( Date: Wed, 14 Oct 2026 12:11:44 +0200 Subject: [PATCH 3/4] test: make test for gameclient update and gamepage --- src/tests/gameClient.test.ts | 85 +++++++++++++++++++++++++++++++----- src/tests/gamepage.test.tsx | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 src/tests/gamepage.test.tsx diff --git a/src/tests/gameClient.test.ts b/src/tests/gameClient.test.ts index defef17..5f9b5d5 100644 --- a/src/tests/gameClient.test.ts +++ b/src/tests/gameClient.test.ts @@ -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 { @@ -11,6 +11,18 @@ function mockResponse(body: unknown, ok = true, status = 200): Response { } as Response } +function stubLocalStorage(values: Map) { + 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() @@ -95,11 +107,7 @@ describe('fetchGamesFromDB', () => { }, ] const values = new Map() - 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) @@ -124,11 +132,7 @@ 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) @@ -136,6 +140,65 @@ describe('fetchGamesFromDB', () => { 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() + 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).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)) diff --git a/src/tests/gamepage.test.tsx b/src/tests/gamepage.test.tsx new file mode 100644 index 0000000..cdb0f64 --- /dev/null +++ b/src/tests/gamepage.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import type { Game } from '../backend/steam' + +const { getGameById, getGames } = vi.hoisted(() => ({ + getGameById: vi.fn<(appid: number) => Promise>(), + getGames: vi.fn(), +})) + +vi.mock('../api/gameClient', () => ({ getGameById, getGames })) +vi.mock('../components/activegamecard', () => ({ + default: ({ game }: { game: { name: string } }) =>
{game.name}
, +})) + +import GamePage from '../pages/gamepage' + +function makeGame(appid: number): Game { + return { + appid, + name: `Game ${appid}`, + price: '9.99', + initialprice: '19.99', + discount: '50', + img: `https://example.com/game-${appid}.jpg`, + genre: 'Action', + description: `Description for game ${appid}`, + } +} + +function renderGamePage(path: string) { + return render( + + + } /> + } /> + + , + ) +} + +describe('GamePage', () => { + beforeEach(() => { + getGameById.mockReset() + getGames.mockReset() + }) + + afterEach(() => { + cleanup() + }) + + it('fetches and renders only the game matching the route id', async () => { + getGameById.mockResolvedValue(makeGame(42)) + + renderGamePage('/game/42') + + expect(await screen.findAllByText('Game 42')).toHaveLength(2) + expect(getGameById).toHaveBeenCalledExactlyOnceWith(42) + expect(getGames).not.toHaveBeenCalled() + }) + + it('shows loading while the requested game is being fetched', () => { + getGameById.mockReturnValue(new Promise(() => {})) + + renderGamePage('/game/42') + + expect(screen.getAllByText('Loading game...')).toHaveLength(2) + expect(getGameById).toHaveBeenCalledExactlyOnceWith(42) + expect(getGames).not.toHaveBeenCalled() + }) + + it('does not fetch when the route id is invalid', () => { + renderGamePage('/game/not-a-number') + + expect(screen.getByText('Game not found.')).toBeTruthy() + expect(getGameById).not.toHaveBeenCalled() + expect(getGames).not.toHaveBeenCalled() + }) +}) From 93b6870a161c865ea1315550e7f7769ca56737f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eline=20J=C3=A5sund?= Date: Wed, 14 Oct 2026 12:12:11 +0200 Subject: [PATCH 4/4] fix: handle failing tests --- src/tests/pageheading.test.tsx | 1 + src/tests/searchpage.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tests/pageheading.test.tsx b/src/tests/pageheading.test.tsx index 6ab83be..f1498a0 100644 --- a/src/tests/pageheading.test.tsx +++ b/src/tests/pageheading.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { Link, MemoryRouter, Route, Routes } from 'react-router-dom' diff --git a/src/tests/searchpage.test.tsx b/src/tests/searchpage.test.tsx index 16f22a8..1a75134 100644 --- a/src/tests/searchpage.test.tsx +++ b/src/tests/searchpage.test.tsx @@ -97,7 +97,7 @@ describe('SearchPage', () => { fireEvent.click(filterToggle) expect(filterToggle.getAttribute('aria-pressed')).toBe('true') - expect(screen.getByText('Sort by:')).toBeTruthy() - expect(screen.getByText('Filter by:')).toBeTruthy() + expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeTruthy() + expect(screen.getByRole('combobox', { name: 'Filter by genre' })).toBeTruthy() }) })