diff --git a/T04-Project-2/backend/test/init.sql b/T04-Project-2/backend/test/init.sql
index 6d41455..07c9212 100644
--- a/T04-Project-2/backend/test/init.sql
+++ b/T04-Project-2/backend/test/init.sql
@@ -15,3 +15,10 @@ INSERT INTO games (id, rawg_id, name, slug, alternative_names, description, rele
(2, 313843, 'Goat Racing', 'goat-racing', ARRAY[]::text[], '
Race goats.
', '2021-02-03', 3.5, 5);
INSERT INTO genres (id, rawg_id, name, description) VALUES (1, 1, 'Adventure', 'Adventure games');
INSERT INTO game_genres VALUES (1, 1);
+
+-- Dedicated fixtures keep review and pagination journeys independent of other tests.
+INSERT INTO games (id, rawg_id, name, slug, description, ratings_count)
+VALUES (3, 90003, 'Review Fixture', 'review-fixture', 'Review fixture description.
', 1);
+INSERT INTO games (id, rawg_id, name, slug, ratings_count)
+SELECT 100 + n, 90100 + n, 'Pagination Fixture ' || lpad(n::text, 2, '0'), 'pagination-' || n, 100 - n
+FROM generate_series(1, 17) AS n;
diff --git a/T04-Project-2/frontend/tests/components/FavoritesProvider.test.tsx b/T04-Project-2/frontend/tests/components/FavoritesProvider.test.tsx
new file mode 100644
index 0000000..702d014
--- /dev/null
+++ b/T04-Project-2/frontend/tests/components/FavoritesProvider.test.tsx
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FavoritesProvider } from '../../src/components/FavoritesProvider'
+import { useFavorites } from '../../src/components/useFavorites'
+
+function FavoritesControls() {
+ const { favoriteIds, toggleFavorite, storageError } = useFavorites()
+ return <>Favorites: {favoriteIds.join(', ') || 'none'}
{storageError && {storageError}
}>
+}
+
+afterEach(() => localStorage.clear())
+describe('favorites', () => {
+ it('adds and removes a favorite and persists the changes', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
+ expect(screen.getByText('Favorites: 42')).toBeInTheDocument()
+ expect(JSON.parse(localStorage.getItem('gaminggoat:favorites:7')!)).toEqual(['42'])
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
+ expect(screen.getByText('Favorites: none')).toBeInTheDocument()
+ expect(JSON.parse(localStorage.getItem('gaminggoat:favorites:7')!)).toEqual([])
+ })
+ it('restores saved favorites and ignores invalid IDs', async () => {
+ localStorage.setItem('gaminggoat:favorites:7', '["42"]')
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Invalid game' }))
+ expect(screen.getByText('Favorites: 42')).toBeInTheDocument()
+ })
+ it('reports corrupt stored data', () => {
+ localStorage.setItem('gaminggoat:favorites:7', '["bad"]')
+ render()
+ expect(screen.getByRole('alert')).toHaveTextContent('invalid format')
+ })
+ it('does not save favorites when logged out', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
+ expect(screen.getByText('Favorites: none')).toBeInTheDocument()
+ expect(localStorage.length).toBe(0)
+ })
+})
diff --git a/T04-Project-2/frontend/tests/components/FilterDropdown.test.tsx b/T04-Project-2/frontend/tests/components/FilterDropdown.test.tsx
new file mode 100644
index 0000000..35206a0
--- /dev/null
+++ b/T04-Project-2/frontend/tests/components/FilterDropdown.test.tsx
@@ -0,0 +1,26 @@
+import { useState } from 'react'
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FilterDropdown } from '../../src/components/FilterDropdown'
+
+function ControlledFilter() {
+ const [selected, setSelected] = useState([])
+ return
+}
+
+describe('genre filter', () => {
+ it('opens and allows selecting and clearing multiple genres', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: /genres/i }))
+ const action = screen.getByRole('checkbox', { name: 'Action' })
+ const puzzle = screen.getByRole('checkbox', { name: 'Puzzle' })
+ await userEvent.click(action)
+ await userEvent.click(puzzle)
+ expect(action).toBeChecked()
+ expect(puzzle).toBeChecked()
+ await userEvent.click(action)
+ expect(action).not.toBeChecked()
+ expect(puzzle).toBeChecked()
+ })
+})
diff --git a/T04-Project-2/frontend/tests/components/LoginPage.test.tsx b/T04-Project-2/frontend/tests/components/LoginPage.test.tsx
new file mode 100644
index 0000000..c8db7b8
--- /dev/null
+++ b/T04-Project-2/frontend/tests/components/LoginPage.test.tsx
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import LoginPage from '../../src/components/LoginPage'
+import * as auth from '../../src/api/auth'
+
+vi.mock('../../src/api/auth', () => ({ login: vi.fn(), createUser: vi.fn() }))
+function setup() {
+ const onAuthenticated = vi.fn()
+ const onClose = vi.fn()
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+ render()
+ return { onAuthenticated, onClose }
+}
+
+describe('login and account creation', () => {
+ it('reports incorrect login credentials', async () => {
+ vi.mocked(auth.login).mockResolvedValueOnce(null)
+ setup()
+ await userEvent.type(screen.getByLabelText('Username'), 'goat')
+ await userEvent.type(screen.getByLabelText('Password'), 'wrong')
+ await userEvent.click(screen.getByRole('button', { name: 'Log in' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Incorrect username or password')
+ })
+ it('completes login with valid credentials', async () => {
+ vi.mocked(auth.login).mockResolvedValueOnce({ id: '1', username: 'goat' })
+ const { onAuthenticated, onClose } = setup()
+ await userEvent.type(screen.getByLabelText('Username'), 'goat')
+ await userEvent.type(screen.getByLabelText('Password'), 'pass')
+ await userEvent.click(screen.getByRole('button', { name: 'Log in' }))
+ await screen.findByRole('heading', { name: 'Welcome back' })
+ await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith({ id: '1', username: 'goat' }, expect.any(String)))
+ expect(onClose).toHaveBeenCalled()
+ })
+ it('rejects mismatched passwords without submitting', async () => {
+ setup()
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }))
+ await userEvent.type(screen.getByLabelText('Username'), 'goat')
+ await userEvent.type(screen.getByLabelText('Password', { exact: true }), 'pass')
+ await userEvent.type(screen.getByLabelText('Confirm password'), 'different')
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }))
+ expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match')
+ expect(auth.createUser).not.toHaveBeenCalled()
+ })
+})
diff --git a/T04-Project-2/frontend/tests/integration/api.spec.ts b/T04-Project-2/frontend/tests/integration/api.spec.ts
index 727fff0..f31ebea 100644
--- a/T04-Project-2/frontend/tests/integration/api.spec.ts
+++ b/T04-Project-2/frontend/tests/integration/api.spec.ts
@@ -25,3 +25,68 @@ test('returns empty results and rejects invalid IDs', async ({ request }) => {
expect(body.data.game).toBeNull()
expect(body.errors).toHaveLength(1)
})
+
+test('finds games by an alternative name', async ({ request }) => {
+ const response = await request.post(api, { data: { query: '{ searchGames(search: "hoof") { id name } }' } })
+ const body = await response.json()
+ expect(body.errors).toBeUndefined()
+ expect(body.data.searchGames).toEqual([{ id: '1', name: 'Goat Quest' }])
+})
+
+test('returns game details and related genres, and null for an unknown game', async ({ request }) => {
+ const response = await request.post(api, { data: { query: '{ game(id: "1") { id name genres { name } } missing: game(id: "99999") { id } }' } })
+ const body = await response.json()
+ expect(body.errors).toBeUndefined()
+ expect(body.data).toEqual({ game: { id: '1', name: 'Goat Quest', genres: [{ name: 'Adventure' }] }, missing: null })
+})
+
+test('returns zero review and favorite counts for a game without activity', async ({ request }) => {
+ const response = await request.post(api, { data: { query: '{ reviewCount(gameId: "1") favoriteCount(gameId: "1") }' } })
+ const body = await response.json()
+ expect(body.errors).toBeUndefined()
+ expect(body.data).toEqual({ reviewCount: 0, favoriteCount: 0 })
+})
+
+test('filters genres and rating and sorts before pagination', async ({ request }) => {
+ const sorted = await request.post(api, { data: { query: '{ searchGames(search: "goat", sortBy: rating, sortOrder: ASC, limit: 1) { name } }' } })
+ expect((await sorted.json()).data.searchGames).toEqual([{ name: 'Goat Racing' }])
+ const filtered = await request.post(api, { data: { query: '{ searchGames(search: "goat", filter: { genres: ["1"], minRating: 4 }) { name } }' } })
+ expect((await filtered.json()).data.searchGames).toEqual([{ name: 'Goat Quest' }])
+})
+
+test('creates an account, authenticates, and creates and deletes a review', async ({ request }) => {
+ const username = `test_${Date.now()}`
+ const send = async (query: string, variables: Record = {}, userId?: string) => {
+ const response = await request.post(api, { headers: userId ? { 'x-user-id': userId } : {}, data: { query, variables } })
+ return response.json()
+ }
+ const create = 'mutation($username: String!, $password: String!) { createUser(username: $username, password: $password) { id username } }'
+ const credentials = { username, password: 'test-password' }
+ const user = await send(create, credentials)
+ expect(user.errors).toBeUndefined()
+ expect(user.data.createUser.username).toBe(username)
+ const id = user.data.createUser.id
+ expect((await send(create, credentials)).errors).toBeDefined()
+ const login = 'mutation($username: String!, $password: String!) { login(username: $username, password: $password) { id username } }'
+ expect((await send(login, credentials)).data.login.id).toBe(id)
+ expect((await send(login, { ...credentials, password: 'incorrect' })).data.login).toBeNull()
+ const review = 'mutation($text: String!) { createReview(gameId: "2", description: $text) { description } }'
+ expect((await send(review, { text: 'Good game' })).errors).toBeDefined()
+ expect((await send(review, { text: ' ' }, id)).errors).toBeDefined()
+ expect((await send(review, { text: 'Good game' }, id)).data.createReview.description).toBe('Good game')
+ expect((await send('{ reviewCount(gameId: "2") }')).data.reviewCount).toBe(1)
+ expect((await send(review, { text: 'Duplicate' }, id)).errors).toBeDefined()
+ expect((await send('mutation { deleteReview(gameId: "2") }', {}, id)).data.deleteReview).toBe(true)
+ expect((await send('{ reviewCount(gameId: "2") }')).data.reviewCount).toBe(0)
+})
+
+
+test('pagination returns distinct results across pages', async ({ request }) => {
+ const query = 'query($offset: Int!) { searchGames(search: "goat", limit: 1, offset: $offset) { id name } }'
+ const first = await request.post(api, { data: { query, variables: { offset: 0 } } })
+ const second = await request.post(api, { data: { query, variables: { offset: 1 } } })
+ const firstPage = (await first.json()).data.searchGames
+ const secondPage = (await second.json()).data.searchGames
+ expect(firstPage).toEqual([{ id: '1', name: 'Goat Quest' }])
+ expect(secondPage).toEqual([{ id: '2', name: 'Goat Racing' }])
+})
diff --git a/T04-Project-2/frontend/tests/integration/journey.spec.ts b/T04-Project-2/frontend/tests/integration/journey.spec.ts
index cd9ffa6..c8a25af 100644
--- a/T04-Project-2/frontend/tests/integration/journey.spec.ts
+++ b/T04-Project-2/frontend/tests/integration/journey.spec.ts
@@ -15,3 +15,91 @@ test('searches, opens a game, reads its details and returns home', async ({ page
await page.getByRole('searchbox').clear()
await expect(page.getByRole('heading', { name: /Find your next/ })).toBeVisible()
})
+
+test('creates an account and manages a favorite across pages', async ({ page }) => {
+ await page.goto('./')
+ await page.getByRole('link', { name: 'Login', exact: true }).click()
+ await page.getByRole('button', { name: 'Create account' }).click()
+ await page.getByLabel('Username').fill(`browser_${Date.now()}`)
+ await page.getByLabel('Password', { exact: true }).fill('test-password')
+ await page.getByLabel('Confirm password').fill('test-password')
+ await page.getByRole('button', { name: 'Create account' }).click()
+ await expect(page.getByRole('link', { name: 'Favorites' })).toBeVisible()
+ await page.getByRole('searchbox').fill('goat quest')
+ await page.getByRole('button', { name: 'Add Goat Quest to favorites' }).click()
+ await page.getByRole('link', { name: 'Favorites' }).click()
+ await expect(page.getByRole('link', { name: 'View details for Goat Quest' })).toBeVisible()
+ await page.getByRole('button', { name: 'Remove Goat Quest from favorites' }).click()
+ await expect(page.getByRole('heading', { name: 'No favorites yet' })).toBeVisible()
+})
+
+test('opens filters using the keyboard and applies and resets a genre', async ({ page }) => {
+ await page.goto('./')
+ await page.getByRole('searchbox').fill('goat')
+ const filter = page.getByRole('button', { name: 'Filter', exact: true })
+ await filter.focus()
+ await page.keyboard.press('Enter')
+ await page.getByRole('button', { name: 'Genres', exact: true }).click()
+ await page.getByRole('checkbox', { name: 'Adventure' }).check()
+ await page.getByRole('button', { name: 'Apply filter' }).click()
+ await expect(page.getByRole('link', { name: 'View details for Goat Quest' })).toBeVisible()
+ await expect(page.getByRole('link', { name: 'View details for Goat Racing' })).toHaveCount(0)
+ await filter.click()
+ await page.getByRole('button', { name: 'Reset' }).click()
+ await expect(page.getByRole('link', { name: 'View details for Goat Racing' })).toBeVisible()
+})
+
+test('shows an error when a logged-out user posts a review', async ({ page }) => {
+ await page.goto('./games/1')
+ await page.getByLabel('Your review').fill('A thoughtful review')
+ await page.getByRole('button', { name: 'Post review' }).click()
+ await expect(page.getByRole('alert')).toContainText('You must be logged in')
+})
+
+test('opens and closes the filter menu on a mobile viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('./')
+ await page.getByRole('searchbox').fill('goat')
+ await page.getByRole('button', { name: 'Filter', exact: true }).click()
+ await expect(page.getByRole('heading', { name: 'Filters' })).toBeVisible()
+ await page.getByRole('button', { name: 'Close filters' }).click()
+ await expect(page.getByRole('heading', { name: 'Filters' })).not.toBeVisible()
+})
+
+test('signed-in user posts and deletes a review and sees the count update', async ({ page }) => {
+ await page.goto('./')
+ await page.getByRole('link', { name: 'Login', exact: true }).click()
+ await page.getByRole('button', { name: 'Create account' }).click()
+ await page.getByLabel('Username').fill(`review_${Date.now()}`)
+ await page.getByLabel('Password', { exact: true }).fill('test-password')
+ await page.getByLabel('Confirm password').fill('test-password')
+ await page.getByRole('button', { name: 'Create account' }).click()
+ await expect(page.getByRole('link', { name: 'Favorites' })).toBeVisible()
+ await page.goto('./games/3')
+ await expect(page.getByRole('heading', { name: 'Player reviews (0)' })).toBeVisible()
+ await page.getByLabel('Your review').fill('A wonderful goat adventure')
+ await page.getByRole('button', { name: 'Post review' }).click()
+ await expect(page.getByText('A wonderful goat adventure')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Player reviews (1)' })).toBeVisible()
+ page.once('dialog', dialog => dialog.accept())
+ await page.getByRole('button', { name: 'Delete your review' }).click()
+ await expect(page.getByText('A wonderful goat adventure')).toHaveCount(0)
+ await expect(page.getByRole('heading', { name: 'Player reviews (0)' })).toBeVisible()
+})
+
+test('loads additional search results without duplicating the first page', async ({ page }) => {
+ await page.goto('./')
+ await page.getByRole('searchbox').fill('Pagination Fixture')
+ await expect(page.getByRole('link', { name: 'View details for Pagination Fixture 01' })).toBeVisible()
+ await expect(page.getByRole('link', { name: 'View details for Pagination Fixture 16' })).toBeVisible()
+ const nextPage = page.waitForResponse(response => {
+ if (!response.url().includes('/graphql') || response.request().method() !== 'POST') return false
+ const body = response.request().postDataJSON()
+ return body?.variables?.search === 'pagination fixture' && body?.variables?.offset === 16
+ })
+ await page.getByRole('link', { name: 'View details for Pagination Fixture 16' }).scrollIntoViewIfNeeded()
+ await nextPage
+ await expect(page.getByRole('link', { name: 'View details for Pagination Fixture 17' })).toBeVisible()
+ await expect(page.getByRole('link', { name: 'View details for Pagination Fixture 01' })).toHaveCount(1)
+ await expect(page.getByRole('listitem')).toHaveCount(17)
+})