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
7 changes: 7 additions & 0 deletions T04-Project-2/backend/test/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ INSERT INTO games (id, rawg_id, name, slug, alternative_names, description, rele
(2, 313843, 'Goat Racing', 'goat-racing', ARRAY[]::text[], '<p>Race goats.</p>', '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', '<p>Review fixture description.</p>', 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;
Original file line number Diff line number Diff line change
@@ -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 <><p>Favorites: {favoriteIds.join(', ') || 'none'}</p>{storageError && <p role="alert">{storageError}</p>}<button onClick={() => toggleFavorite('42')}>Toggle game</button><button onClick={() => toggleFavorite('invalid')}>Invalid game</button></>
}

afterEach(() => localStorage.clear())
describe('favorites', () => {
it('adds and removes a favorite and persists the changes', async () => {
render(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
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(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
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(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
expect(screen.getByRole('alert')).toHaveTextContent('invalid format')
})
it('does not save favorites when logged out', async () => {
render(<FavoritesProvider userId={null}><FavoritesControls /></FavoritesProvider>)
await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
expect(screen.getByText('Favorites: none')).toBeInTheDocument()
expect(localStorage.length).toBe(0)
})
})
26 changes: 26 additions & 0 deletions T04-Project-2/frontend/tests/components/FilterDropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([])
return <FilterDropdown filterName="Genres" label="Genres group" options={[{ value: '1', label: 'Action' }, { value: '2', label: 'Puzzle' }]} selected={selected} onChange={setSelected} />
}

describe('genre filter', () => {
it('opens and allows selecting and clearing multiple genres', async () => {
render(<ControlledFilter />)
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()
})
})
46 changes: 46 additions & 0 deletions T04-Project-2/frontend/tests/components/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}><LoginPage onAuthenticated={onAuthenticated} onClose={onClose} /></QueryClientProvider>)
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()
})
})
65 changes: 65 additions & 0 deletions T04-Project-2/frontend/tests/integration/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}, 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' }])
})
88 changes: 88 additions & 0 deletions T04-Project-2/frontend/tests/integration/journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})