|
|
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
|
|
import {
|
|
|
countActiveQuestions,
|
|
|
countLeaderboardEntriesByPlayer,
|
|
|
questionIsActive,
|
|
|
resetDatabases,
|
|
|
seedQuestions,
|
|
|
} from './helpers/stack'
|
|
|
|
|
|
const apiBase = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096'
|
|
|
|
|
|
async function setInputValue(selector: string, value: string, page: Page): Promise<void> {
|
|
|
await page.locator(selector).evaluate((el, next) => {
|
|
|
const input = el as HTMLInputElement
|
|
|
input.value = next
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
|
}, value)
|
|
|
}
|
|
|
|
|
|
async function assertBackendReachable(
|
|
|
request: Parameters<typeof test>[0]['request']
|
|
|
): Promise<void> {
|
|
|
const resp = await request.post(`${apiBase}/api/v1/questions/random`, { data: {} })
|
|
|
expect(resp.ok()).toBeTruthy()
|
|
|
expect(countActiveQuestions()).toBeGreaterThan(0)
|
|
|
}
|
|
|
|
|
|
test.describe('full-stack parity with frontend e2e scenarios', () => {
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
resetDatabases()
|
|
|
seedQuestions()
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.clear()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
test('player registration and demo login flow', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.setItem('kf.playerName', 'E2E Player')
|
|
|
})
|
|
|
|
|
|
await page.goto('/game')
|
|
|
await expect(page).toHaveURL(/\/game$/)
|
|
|
|
|
|
await page.goto('/profile')
|
|
|
await expect(page.getByText('Connexion requise pour accéder au profil joueur.')).toBeVisible()
|
|
|
await page.getByRole('button', { name: 'Se connecter (mode démo)' }).click()
|
|
|
await expect(page.getByText('Statistiques joueur')).toBeVisible()
|
|
|
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('complete game session flow', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.setItem(
|
|
|
'kf.lastResult',
|
|
|
JSON.stringify({
|
|
|
playerName: 'Game Runner',
|
|
|
finalScore: 2,
|
|
|
answered: 1,
|
|
|
correct: 1,
|
|
|
successRate: 100,
|
|
|
durationSec: 45,
|
|
|
leaderboardPosition: 1,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
})
|
|
|
)
|
|
|
})
|
|
|
await page.goto('/results')
|
|
|
|
|
|
await expect(page).toHaveURL(/\/results$/)
|
|
|
await expect(page.getByText('Score final : 2')).toBeVisible()
|
|
|
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('leaderboard viewing flow', async ({ page, request }) => {
|
|
|
const playerID = `player-${Date.now()}`
|
|
|
const playerName = `Parity${Date.now().toString().slice(-4)}`
|
|
|
const resp = await request.post(`${apiBase}/api/v1/leaderboard/update`, {
|
|
|
data: {
|
|
|
session_id: `sess-${Date.now()}`,
|
|
|
player_id: playerID,
|
|
|
player_name: playerName,
|
|
|
total_score: 11,
|
|
|
questions_asked: 6,
|
|
|
questions_correct: 5,
|
|
|
hints_used: 1,
|
|
|
duration_seconds: 123,
|
|
|
completed_at: new Date().toISOString(),
|
|
|
completion_type: 'completed',
|
|
|
},
|
|
|
})
|
|
|
expect(resp.ok()).toBeTruthy()
|
|
|
|
|
|
await page.goto('/leaderboard')
|
|
|
await expect(page.getByRole('heading', { name: 'Leaderboard' })).toBeVisible()
|
|
|
await expect(page.getByRole('table', { name: 'top-10-leaderboard' })).toBeVisible()
|
|
|
await expect(page.getByText(playerName)).toBeVisible()
|
|
|
expect(countLeaderboardEntriesByPlayer(playerID)).toBe(1)
|
|
|
})
|
|
|
|
|
|
test('admin question management flow', async ({ page, request }) => {
|
|
|
await page.goto('/admin/questions')
|
|
|
await expect(page.getByText('Quel est le plus petit nombre premier ?')).toBeVisible()
|
|
|
await page.getByRole('button', { name: 'Supprimer' }).first().click()
|
|
|
await expect(page.getByText('Quel est le plus petit nombre premier ?')).toHaveCount(0)
|
|
|
|
|
|
const deleteResp = await request.delete(
|
|
|
`${apiBase}/api/v1/admin/questions/00000000-0000-0000-0000-000000000001`
|
|
|
)
|
|
|
expect(deleteResp.status()).toBe(204)
|
|
|
expect(questionIsActive('00000000-0000-0000-0000-000000000001')).toBeFalsy()
|
|
|
})
|
|
|
|
|
|
test('profile route requires auth, then allows access after demo login', async ({
|
|
|
page,
|
|
|
request,
|
|
|
}) => {
|
|
|
await page.goto('/profile')
|
|
|
|
|
|
await expect(page.getByText('Connexion requise pour accéder au profil joueur.')).toBeVisible()
|
|
|
await page.getByRole('button', { name: 'Se connecter (mode démo)' }).click()
|
|
|
|
|
|
await expect(page.getByText('Statistiques joueur')).toBeVisible()
|
|
|
await expect(page.getByText(/Parties jouées/i)).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('start new game from home and render first playable question', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await setInputValue('[data-testid="home-player-name-input"]', 'E2E Player', page)
|
|
|
await page.getByRole('button', { name: 'Démarrer la partie' }).click()
|
|
|
|
|
|
await expect(page).toHaveURL(/\/game$/)
|
|
|
await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible()
|
|
|
await expect(page.getByText('Thème : Général')).toBeVisible()
|
|
|
await expect(page.getByText('Quel est le plus petit nombre premier ?')).toBeVisible()
|
|
|
await expect(page.getByLabel('Ta réponse')).toBeVisible()
|
|
|
await expect(page.getByText('3 restant(s)')).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('hint usage reduces awarded score for a correct answer', async ({ page, request }) => {
|
|
|
await page.goto('/game')
|
|
|
|
|
|
await page.getByRole('button', { name: 'Indice (score réduit)' }).click()
|
|
|
await expect(page.getByText('Confirmer l’indice')).toBeVisible()
|
|
|
await page.locator('button', { hasText: 'Oui, utiliser un indice' }).click()
|
|
|
await expect(page.getByText(/Indice:/i)).toBeVisible()
|
|
|
|
|
|
await setInputValue('[data-testid="game-answer-input"]', '2', page)
|
|
|
await page.getByRole('button', { name: 'Envoyer' }).click()
|
|
|
await expect(page.getByText('Score: 1')).toBeVisible()
|
|
|
|
|
|
await setInputValue('[data-testid="game-answer-input"]', 'mars', page)
|
|
|
await page.getByRole('button', { name: 'Envoyer' }).click()
|
|
|
await expect(page).toHaveURL(/\/results$/)
|
|
|
await expect(page.getByText('Score final : 3')).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('after 3 wrong answers, game advances to next question', async ({ page, request }) => {
|
|
|
await page.goto('/game')
|
|
|
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
|
|
|
await page.getByRole('button', { name: 'Envoyer' }).click()
|
|
|
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
|
|
|
await page.getByRole('button', { name: 'Envoyer' }).click()
|
|
|
await setInputValue('[data-testid="game-answer-input"]', 'wrong', page)
|
|
|
await page.getByRole('button', { name: 'Envoyer' }).click()
|
|
|
|
|
|
await expect(page).toHaveURL(/\/game$/)
|
|
|
await expect(page.getByText('Quelle planète est surnommée la planète rouge ?')).toBeVisible()
|
|
|
await expect(page.getByText("Plus d'essais.")).not.toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('session timeout ends game and redirects to results', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.setItem('kf.e2e.timerMs', '300')
|
|
|
})
|
|
|
await page.goto('/game')
|
|
|
await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible()
|
|
|
await expect(page).toHaveURL(/\/results$/)
|
|
|
await expect(page.getByText('Score final : 0')).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('results remain visible after page reload', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.setItem(
|
|
|
'kf.lastResult',
|
|
|
JSON.stringify({
|
|
|
playerName: 'Reload Tester',
|
|
|
finalScore: 7,
|
|
|
answered: 4,
|
|
|
correct: 3,
|
|
|
successRate: 75,
|
|
|
durationSec: 95,
|
|
|
leaderboardPosition: 2,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
})
|
|
|
)
|
|
|
})
|
|
|
await page.goto('/results')
|
|
|
await expect(page.getByText('Score final : 7')).toBeVisible()
|
|
|
await page.reload()
|
|
|
await expect(page.getByText('Score final : 7')).toBeVisible()
|
|
|
await expect(page.getByText('Position leaderboard : #2')).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
|
|
|
test('leaderboard shows empty and error states from API', async ({ page }) => {
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.setItem('kf.leaderboard.mode', 'api')
|
|
|
})
|
|
|
await page.goto('/leaderboard')
|
|
|
await expect(page.getByText('Aucun score pour le moment.')).toBeVisible()
|
|
|
|
|
|
await page.route('**/leaderboard/top10', async (route) => {
|
|
|
await route.fulfill({
|
|
|
status: 500,
|
|
|
contentType: 'application/json',
|
|
|
body: JSON.stringify({ error: 'boom' }),
|
|
|
})
|
|
|
})
|
|
|
await page.getByRole('button', { name: 'Rafraîchir' }).click()
|
|
|
await expect(
|
|
|
page.getByText('Impossible de charger le leaderboard. Veuillez réessayer.')
|
|
|
).toBeVisible()
|
|
|
})
|
|
|
|
|
|
test('admin can create, edit, then delete a question', async ({ request }) => {
|
|
|
const createResp = await request.post(`${apiBase}/api/v1/admin/questions`, {
|
|
|
data: {
|
|
|
theme: 'Science',
|
|
|
text: 'Quelle planète est rouge ?',
|
|
|
answer: 'Mars',
|
|
|
hint: '4e planète',
|
|
|
difficulty: 'medium',
|
|
|
},
|
|
|
})
|
|
|
expect(createResp.status()).toBe(201)
|
|
|
const created = await createResp.json()
|
|
|
const id = created.data.id as string
|
|
|
|
|
|
const updateResp = await request.put(`${apiBase}/api/v1/admin/questions/${id}`, {
|
|
|
data: {
|
|
|
theme: 'Science',
|
|
|
text: 'Quelle planète est appelée planète rouge ?',
|
|
|
answer: 'Mars',
|
|
|
hint: '4e planète',
|
|
|
difficulty: 'hard',
|
|
|
is_active: true,
|
|
|
},
|
|
|
})
|
|
|
expect(updateResp.ok()).toBeTruthy()
|
|
|
|
|
|
const deleteResp = await request.delete(`${apiBase}/api/v1/admin/questions/${id}`)
|
|
|
expect(deleteResp.status()).toBe(204)
|
|
|
expect(questionIsActive(id)).toBeFalsy()
|
|
|
})
|
|
|
|
|
|
test('admin form validation blocks invalid submission', async ({ page }) => {
|
|
|
await page.goto('/admin/questions')
|
|
|
await page.getByTestId('admin-theme-input').fill('')
|
|
|
await page.getByTestId('admin-question-input').fill('')
|
|
|
await page.getByTestId('admin-answer-input').fill('')
|
|
|
await page.getByTestId('admin-create-question').click()
|
|
|
await expect(page.getByText('Theme, question et réponse sont requis.')).toBeVisible()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
test.describe('full-stack parity mobile scenario', () => {
|
|
|
test.use({ viewport: { width: 390, height: 844 } })
|
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
resetDatabases()
|
|
|
seedQuestions()
|
|
|
await page.goto('/')
|
|
|
await page.evaluate(() => {
|
|
|
localStorage.clear()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
test('mobile player flow keeps core controls visible', async ({ page, request }) => {
|
|
|
await page.goto('/')
|
|
|
await setInputValue('[data-testid="home-player-name-input"]', 'Mobile Player', page)
|
|
|
await page.getByRole('button', { name: 'Démarrer la partie' }).click()
|
|
|
|
|
|
await expect(page).toHaveURL(/\/game$/)
|
|
|
await expect(page.getByRole('button', { name: 'Envoyer' })).toBeVisible()
|
|
|
await expect(page.getByRole('button', { name: 'Indice (score réduit)' })).toBeVisible()
|
|
|
await expect(page.getByLabel('Ta réponse')).toBeVisible()
|
|
|
await assertBackendReachable(request)
|
|
|
})
|
|
|
})
|