Added additional fullstack end to end tests

master
oabrivard 4 weeks ago
parent c2b81bdac7
commit 9dd7d74c70

@ -0,0 +1,52 @@
# Full-Stack E2E Integration Tests Plan
## Goal
Ensure full-stack end-to-end integration tests cover at least the same scenarios as frontend-only E2E tests, while adding backend and database assertions where feasible.
## Strategy
1. Build a source-to-fullstack scenario parity matrix.
2. Implement full-stack tests that mirror source scenario names.
3. Add typed DB helpers for deterministic assertions.
4. Add a parity gate script that fails if full-stack coverage drops below source coverage.
5. Wire parity and full-stack execution into standard local commands.
## Coverage Matrix
| Source E2E Scenario | Full-Stack Equivalent |
| --- | --- |
| player registration and demo login flow | player registration and demo login flow |
| complete game session flow | complete game session flow |
| leaderboard viewing flow | leaderboard viewing flow |
| admin question management flow | admin question management flow |
| profile route requires auth, then allows access after demo login | profile route requires auth, then allows access after demo login |
| start new game from home and render first playable question | start new game from home and render first playable question |
| hint usage reduces awarded score for a correct answer | hint usage reduces awarded score for a correct answer |
| after 3 wrong answers, game advances to next question | after 3 wrong answers, game advances to next question |
| session timeout ends game and redirects to results | session timeout ends game and redirects to results |
| results remain visible after page reload | results remain visible after page reload |
| leaderboard shows empty and error states from API | leaderboard shows empty and error states from API |
| admin can create, edit, then delete a question | admin can create, edit, then delete a question |
| admin form validation blocks invalid submission | admin form validation blocks invalid submission |
| mobile player flow keeps core controls visible | mobile player flow keeps core controls visible |
## Implementation Phases
### Phase 1: Parity Suite
- Add a dedicated full-stack parity spec mirroring all source E2E scenario names.
- Keep tests serial and deterministic.
### Phase 2: DB and API Assertion Helpers
- Extend full-stack helpers with typed assertion/query utilities.
- Add scenario-specific reset/seed calls before each test.
### Phase 3: Parity Gate
- Add a script that parses source and full-stack test names.
- Fail if any source test name has no full-stack counterpart.
### Phase 4: Command Wiring
- Add package script for parity check.
- Keep `test:e2e:fullstack` as execution command for the integration suite.
## Exit Criteria
- All source E2E scenarios have matching full-stack scenario names.
- Full-stack suite passes.
- Parity check passes and can be run locally/CI.

@ -54,6 +54,23 @@ export function queryScalar(dbName: string, sql: string): string {
]) ])
} }
export function queryRows(dbName: string, sql: string): string {
return run('docker', [
'exec',
postgresContainer,
'psql',
'-U',
'knowfoolery',
'-d',
dbName,
'-A',
'-F',
'|',
'-c',
sql,
])
}
export function resetDatabases(): void { export function resetDatabases(): void {
queryScalar('questions', 'TRUNCATE TABLE questions RESTART IDENTITY CASCADE;') queryScalar('questions', 'TRUNCATE TABLE questions RESTART IDENTITY CASCADE;')
queryScalar( queryScalar(
@ -76,6 +93,39 @@ ON CONFLICT (id) DO NOTHING;
) )
} }
export function countActiveQuestions(): number {
return Number.parseInt(
queryScalar('questions', 'SELECT COUNT(*) FROM questions WHERE is_active = true;'),
10
)
}
export function countLeaderboardEntries(): number {
return Number.parseInt(
queryScalar('leaderboards', 'SELECT COUNT(*) FROM leaderboard_entries;'),
10
)
}
export function countLeaderboardEntriesByPlayer(playerID: string): number {
return Number.parseInt(
queryScalar(
'leaderboards',
`SELECT COUNT(*) FROM leaderboard_entries WHERE player_id = ${sqlLiteral(playerID)};`
),
10
)
}
export function questionIsActive(questionID: string): boolean {
return (
queryScalar(
'questions',
`SELECT is_active::text FROM questions WHERE id = ${sqlLiteral(questionID)};`
) === 'true'
)
}
export async function bringUpFullStack(): Promise<void> { export async function bringUpFullStack(): Promise<void> {
fs.copyFileSync(envFile, prodEnvFile) fs.copyFileSync(envFile, prodEnvFile)

@ -0,0 +1,305 @@
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 lindice')).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)
})
})

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/services/session.test.ts",{"duration":4,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":3,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":4,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/routes/Home.test.tsx",{"duration":134,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":51,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":117,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":139,"failed":false}],[":src/routes/Results.test.tsx",{"duration":36,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":98,"failed":false}],[":src/routes/Game.test.tsx",{"duration":163,"failed":false}]]} {"version":"1.6.1","results":[[":src/services/session.test.ts",{"duration":4,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":4,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":3,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/services/validation.test.ts",{"duration":3,"failed":false}],[":src/routes/Home.test.tsx",{"duration":122,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":128,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":134,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":48,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":84,"failed":false}],[":src/routes/Results.test.tsx",{"duration":36,"failed":false}],[":src/routes/Game.test.tsx",{"duration":155,"failed":false}]]}

@ -1,4 +1,22 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"847833760f0942085220-1471dfd4442c71d0da76",
"83ca259d6a4f6f998541-f0145fd6eb222509d174",
"1a5533bf5eebe0cc515b-70ca5f1873820161b53d",
"1a5533bf5eebe0cc515b-782c944811d8f92e614e",
"1a5533bf5eebe0cc515b-e26e4613ad89959638dc",
"1a5533bf5eebe0cc515b-a725afc66ce7751fb5ab",
"1a5533bf5eebe0cc515b-e53427a85937ee7063c0",
"1a5533bf5eebe0cc515b-5ff8ce13eba7e3f027d8",
"1a5533bf5eebe0cc515b-87eaacb7a57bf52b9585",
"1a5533bf5eebe0cc515b-1112346923e720f1a0d3",
"1a5533bf5eebe0cc515b-543b02dd778e8efeb628",
"1a5533bf5eebe0cc515b-565e338e9eceb24acbdc",
"1a5533bf5eebe0cc515b-e9ad1e0b8fd00d286c5d",
"1a5533bf5eebe0cc515b-b28a31c390b26a2b570f",
"1a5533bf5eebe0cc515b-0056a4ff41c1b12788a8",
"1a5533bf5eebe0cc515b-a8b41500e036db4cd4b2",
"b3734b29a45147dc0d58-6c2492472bac455a96da"
]
} }

@ -15,7 +15,9 @@
"test": "yarn workspaces foreach -A run test", "test": "yarn workspaces foreach -A run test",
"test:e2e": "playwright test -c apps/web/playwright.config.ts", "test:e2e": "playwright test -c apps/web/playwright.config.ts",
"test:e2e:fullstack": "playwright test -c apps/web/playwright.fullstack.config.ts", "test:e2e:fullstack": "playwright test -c apps/web/playwright.fullstack.config.ts",
"clean": "yarn workspaces foreach -A run clean" "clean": "yarn workspaces foreach -A run clean",
"test:e2e:parity": "node scripts/check-e2e-parity.mjs",
"test:e2e:fullstack:all": "yarn test:e2e:parity && yarn test:e2e:fullstack"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",

@ -0,0 +1,59 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
const root = path.resolve(process.cwd())
const sourceSpecs = [
path.join(root, 'apps/web/e2e/critical-flows.spec.ts'),
path.join(root, 'apps/web/e2e/critical-flows-extended.spec.ts'),
]
const fullstackDir = path.join(root, 'apps/web/e2e/full-stack')
function read(filePath) {
return fs.readFileSync(filePath, 'utf8')
}
function extractTestNames(content) {
const names = []
const regex = /\btest\s*\(\s*(['"])(.*?)\1/g
let match
while ((match = regex.exec(content)) !== null) {
names.push(match[2])
}
return names
}
function listFullstackSpecs(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.spec.ts'))
.map((entry) => path.join(dir, entry.name))
}
const sourceNames = new Set()
for (const spec of sourceSpecs) {
for (const name of extractTestNames(read(spec))) {
sourceNames.add(name)
}
}
const fullstackNames = new Set()
for (const spec of listFullstackSpecs(fullstackDir)) {
for (const name of extractTestNames(read(spec))) {
fullstackNames.add(name)
}
}
const missing = [...sourceNames]
.filter((name) => !fullstackNames.has(name))
.sort((a, b) => a.localeCompare(b))
if (missing.length > 0) {
console.error('Full-stack parity check failed. Missing scenarios:')
for (const name of missing) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log(`Full-stack parity check passed (${sourceNames.size} source scenarios covered).`)

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/components/ResultsCard.test.tsx",{"duration":64,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":28,"failed":false}],[":src/components/Timer.test.tsx",{"duration":25,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":30,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":75,"failed":false}],[":src/utils/timer.test.ts",{"duration":1,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":18,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":20,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":18,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":35,"failed":false}]]} {"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":2,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":24,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":72,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":20,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":21,"failed":false}],[":src/components/Timer.test.tsx",{"duration":29,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":39,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":36,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":41,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":80,"failed":false}]]}
Loading…
Cancel
Save