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) +})