Added full end to end integration tests that exercises frontend, backend and persistence stores

master
oabrivard 1 month ago
parent ef49b6ac63
commit c2b81bdac7

@ -39,7 +39,7 @@ BACKEND_MODULES := \
infra-build-images infra-up-prod \
task-security-scan task-deploy-dev task-deploy-prod task-k6-smoke task-k6-load \
backend-lint backend-test backend-build \
frontend-dev frontend-lint frontend-test frontend-e2e frontend-build \
frontend-dev frontend-lint frontend-test frontend-e2e frontend-e2e-fullstack frontend-build \
db-up db-down db-logs db-shell redis-shell \
lint test build
@ -75,6 +75,7 @@ help:
@echo " make frontend-lint - Run ESLint and Prettier"
@echo " make frontend-test - Run frontend tests"
@echo " make frontend-e2e - Run frontend Playwright critical-flow tests"
@echo " make frontend-e2e-fullstack - Run full-stack frontend->gateway->db Playwright tests"
@echo " make frontend-build - Build frontend for production"
@echo ""
@echo "Database:"
@ -194,6 +195,10 @@ frontend-e2e:
@echo "Running frontend Playwright critical-flow tests..."
@cd frontend && yarn test:e2e
frontend-e2e-fullstack:
@echo "Running frontend Playwright full-stack integration tests..."
@cd frontend && yarn test:e2e:fullstack
frontend-build:
@echo "Building frontend..."
@cd frontend && yarn build

@ -312,7 +312,7 @@ INSERT INTO leaderboard_player_stats (
player_id, player_name, games_played, games_completed, total_score, best_score, avg_score,
avg_success_rate, total_questions, total_correct, best_duration_seconds, last_played_at, updated_at
) VALUES (
$1, $2, 1, CASE WHEN $3='completed' THEN 1 ELSE 0 END, $4, $4, $4::numeric, $5, $6, $7, $8, $9, NOW()
$1, $2, 1, CASE WHEN $3='completed' THEN 1 ELSE 0 END, $4::bigint, ($4::bigint)::int, ($4::bigint)::numeric, $5, $6, $7, $8, $9, NOW()
)
ON CONFLICT (player_id)
DO UPDATE SET

@ -0,0 +1,61 @@
# E2E Integration Tests Plan
## Objective
Implement end-to-end integration tests that validate real request paths from frontend test clients to gateway, backend services, and PostgreSQL/Redis persistence.
## Scope
- Validate real backend integration through gateway (`/api/v1`).
- Assert persistence side effects in service databases.
- Keep existing fast frontend-only Playwright tests unchanged.
- Add a dedicated full-stack Playwright suite and dedicated environment bootstrap.
## Success Criteria
- A dedicated command starts full-stack dependencies and runs integration E2E tests.
- At least 3 golden scenarios are covered with API + DB assertions.
- CI-ready artifacts and deterministic reset/seed flow are in place.
- Frontend lint/unit/E2E checks still pass.
## Phases
### Phase 1: Dedicated Full-Stack Environment
1. Create an E2E env file for compose with isolated ports.
2. Reuse production compose stack with auth disabled for test mode.
3. Add global setup/teardown scripts for Playwright to:
- boot required services,
- wait for health readiness,
- reset DB state before suites.
### Phase 2: Data Reset + DB Assertion Tooling
1. Add helper utilities to execute SQL assertions against Postgres in Docker.
2. Add deterministic DB reset/truncate helpers across service databases.
3. Add optional seed helpers for initial baseline data.
### Phase 3: Full-Stack Test Scenarios
1. Leaderboard persistence and UI rendering:
- POST update through gateway,
- assert row in `leaderboards.leaderboard_entries`,
- verify frontend leaderboard page renders persisted player.
2. Admin question CRUD persistence:
- create/update/delete question through gateway admin endpoints,
- assert `questions.questions` row state after each operation.
3. Gateway + service health and contract checks:
- verify integration health endpoints and stable response envelopes.
### Phase 4: Commands and CI Integration
1. Add dedicated Playwright config for full-stack suite.
2. Add `frontend` script to run full-stack E2E.
3. Add `make` command wrapper for full-stack E2E.
4. Keep this suite separate from fast frontend-only E2E.
### Phase 5: Stabilization
1. Use deterministic IDs and explicit cleanup.
2. Force serial execution for integration suite to avoid DB race conditions.
3. Collect actionable failure outputs (HTTP body + SQL counts) in assertions.
## Deliverables
- `infrastructure/e2e/.env.e2e`
- `frontend/apps/web/playwright.fullstack.config.ts`
- `frontend/apps/web/e2e/full-stack/*` tests + helpers + setup/teardown
- `frontend/package.json` script additions
- `Makefile` target additions

@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test'
import { queryScalar, resetDatabases, seedQuestions, sqlLiteral } from './helpers/stack'
const apiBase = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096'
test.describe('full-stack admin question CRUD integration', () => {
test.beforeEach(() => {
resetDatabases()
seedQuestions()
})
test('create/update/delete question persists expected DB state', async ({ request }) => {
const createResp = await request.post(`${apiBase}/api/v1/admin/questions`, {
data: {
theme: 'Science',
text: 'Quelle est la planète la plus chaude du système solaire ?',
answer: 'Vénus',
hint: 'Pas la plus proche du Soleil',
difficulty: 'medium',
},
})
expect(createResp.status()).toBe(201)
const createBody = await createResp.json()
expect(createBody.success).toBeTruthy()
const createdID = createBody.data.id as string
expect(createdID).toBeTruthy()
const createdText = queryScalar(
'questions',
`SELECT text FROM questions WHERE id = ${sqlLiteral(createdID)};`
)
expect(createdText).toContain('planète la plus chaude')
const updateResp = await request.put(`${apiBase}/api/v1/admin/questions/${createdID}`, {
data: {
theme: 'Science',
text: 'Quelle planète est la plus chaude du système solaire ?',
answer: 'Vénus',
hint: 'Atmosphère très dense',
difficulty: 'hard',
is_active: true,
},
})
expect(updateResp.ok()).toBeTruthy()
const updateText = queryScalar(
'questions',
`SELECT text FROM questions WHERE id = ${sqlLiteral(createdID)};`
)
expect(updateText).toContain('la plus chaude du système solaire')
const deleteResp = await request.delete(`${apiBase}/api/v1/admin/questions/${createdID}`)
expect(deleteResp.status()).toBe(204)
const isActive = queryScalar(
'questions',
`SELECT is_active::text FROM questions WHERE id = ${sqlLiteral(createdID)};`
)
expect(isActive).toBe('false')
})
})

@ -0,0 +1,7 @@
import type { FullConfig } from '@playwright/test'
import { bringUpFullStack } from './helpers/stack'
export default async function globalSetup(_config: FullConfig): Promise<void> {
await bringUpFullStack()
}

@ -0,0 +1,7 @@
import type { FullConfig } from '@playwright/test'
import { tearDownFullStack } from './helpers/stack'
export default function globalTeardown(_config: FullConfig): void {
tearDownFullStack()
}

@ -0,0 +1,110 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(here, '../../../../../../')
const composeFile = path.join(repoRoot, 'infrastructure/prod/docker-compose.yml')
const envFile = path.join(repoRoot, 'infrastructure/e2e/.env.e2e')
const prodEnvFile = path.join(repoRoot, 'infrastructure/prod/.env.prod')
const postgresContainer = 'knowfoolery-postgres'
const redisContainer = 'knowfoolery-redis'
const apiBaseURL = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096'
function run(command: string, args: string[]): string {
return execFileSync(command, args, {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
}).trim()
}
async function waitForURL(url: string, timeoutMs: number): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// keep waiting
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
throw new Error(`Timed out waiting for ${url}`)
}
export function sqlLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
export function queryScalar(dbName: string, sql: string): string {
return run('docker', [
'exec',
postgresContainer,
'psql',
'-U',
'knowfoolery',
'-d',
dbName,
'-t',
'-A',
'-c',
sql,
])
}
export function resetDatabases(): void {
queryScalar('questions', 'TRUNCATE TABLE questions RESTART IDENTITY CASCADE;')
queryScalar(
'leaderboards',
'TRUNCATE TABLE leaderboard_entries, leaderboard_player_stats RESTART IDENTITY CASCADE;'
)
run('docker', ['exec', redisContainer, 'redis-cli', 'FLUSHALL'])
}
export function seedQuestions(): void {
queryScalar(
'questions',
`
INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at)
VALUES
('00000000-0000-0000-0000-000000000001', 'Général', 'Quel est le plus petit nombre premier ?', '2', 'Le seul nombre premier pair.', 'easy', true, NOW(), NOW()),
('00000000-0000-0000-0000-000000000002', 'Astronomie', 'Quelle planète est surnommée la planète rouge ?', 'Mars', '4e planète du système solaire.', 'easy', true, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
`
)
}
export async function bringUpFullStack(): Promise<void> {
fs.copyFileSync(envFile, prodEnvFile)
run('docker', [
'compose',
'-f',
composeFile,
'--env-file',
envFile,
'up',
'-d',
'postgres',
'redis',
'question-bank-service',
'leaderboard-service',
'user-service',
'admin-service',
'gateway-service',
'nginx',
])
await waitForURL(`${apiBaseURL}/nginx/health`, 180_000)
await waitForURL(`${apiBaseURL}/health`, 180_000)
await waitForURL(`${apiBaseURL}/api/v1/leaderboard/top10`, 180_000)
resetDatabases()
seedQuestions()
}
export function tearDownFullStack(): void {
run('docker', ['compose', '-f', composeFile, '--env-file', envFile, 'down', '-v'])
}

@ -0,0 +1,52 @@
import { expect, test } from '@playwright/test'
import { queryScalar, resetDatabases, seedQuestions, sqlLiteral } from './helpers/stack'
const apiBase = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096'
test.describe('full-stack leaderboard integration', () => {
test.beforeEach(() => {
resetDatabases()
seedQuestions()
})
test('frontend leaderboard renders data persisted through gateway into DB', async ({
page,
request,
}) => {
const sessionID = `sess-${Date.now()}`
const playerName = `Player${Date.now().toString().slice(-4)}`
const updateResp = await request.post(`${apiBase}/api/v1/leaderboard/update`, {
data: {
session_id: sessionID,
player_id: 'player-001',
player_name: playerName,
total_score: 12,
questions_asked: 6,
questions_correct: 5,
hints_used: 1,
duration_seconds: 150,
completed_at: new Date().toISOString(),
completion_type: 'completed',
},
})
const updateBodyText = await updateResp.text()
expect(updateResp.ok(), updateBodyText).toBeTruthy()
const body = JSON.parse(updateBodyText)
expect(body.success).toBeTruthy()
const count = Number.parseInt(
queryScalar(
'leaderboards',
`SELECT COUNT(*) FROM leaderboard_entries WHERE session_id = ${sqlLiteral(sessionID)};`
),
10
)
expect(count).toBe(1)
await page.goto('/leaderboard')
await expect(page.getByRole('table', { name: 'top-10-leaderboard' })).toBeVisible()
await expect(page.getByText(playerName)).toBeVisible()
})
})

@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'
import { queryScalar, resetDatabases, seedQuestions } from './helpers/stack'
const apiBase = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096'
test.describe('full-stack question-bank integration', () => {
test.beforeEach(() => {
resetDatabases()
seedQuestions()
})
test('gateway forwards random question and answer validation against DB-backed questions', async ({
request,
}) => {
const randomResp = await request.post(`${apiBase}/api/v1/questions/random`, {
data: {},
})
expect(randomResp.ok()).toBeTruthy()
const randomBody = await randomResp.json()
expect(randomBody.success).toBeTruthy()
expect(randomBody.data.id).toBeTruthy()
const questionID = randomBody.data.id as string
const validateResp = await request.post(
`${apiBase}/api/v1/questions/${questionID}/validate-answer`,
{
data: { answer: '2' },
}
)
expect(validateResp.ok()).toBeTruthy()
const validateBody = await validateResp.json()
expect(validateBody.success).toBeTruthy()
expect(typeof validateBody.data.matched).toBe('boolean')
const dbCount = Number.parseInt(
queryScalar('questions', 'SELECT COUNT(*) FROM questions WHERE is_active = true;'),
10
)
expect(dbCount).toBeGreaterThanOrEqual(2)
})
})

@ -1 +1 @@
{"version":"1.6.1","results":[[":src/services/adminQuestions.test.ts",{"duration":2,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":3,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/services/session.test.ts",{"duration":3,"failed":false}],[":src/routes/Home.test.tsx",{"duration":104,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":42,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/routes/Results.test.tsx",{"duration":30,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":86,"failed":false}],[":src/routes/Game.test.tsx",{"duration":147,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":115,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":108,"failed":false}]]}
{"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}]]}

@ -0,0 +1,31 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@playwright/test'
const configDir = path.dirname(fileURLToPath(import.meta.url))
const frontendRoot = path.resolve(configDir, '../..')
export default defineConfig({
testDir: './e2e/full-stack',
timeout: 60_000,
workers: 1,
globalSetup: './e2e/full-stack/global.setup.ts',
globalTeardown: './e2e/full-stack/global.teardown.ts',
use: {
baseURL: process.env.PW_BASE_URL ?? 'http://127.0.0.1:4175',
headless: true,
},
webServer: {
command: 'yarn workspace @knowfoolery/web dev --host 127.0.0.1 --port 4175',
cwd: frontendRoot,
port: 4175,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
env: {
...process.env,
VITE_API_BASE_URL: process.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18096/api/v1',
VITE_LEADERBOARD_FORCE_API: 'true',
},
},
})

@ -23,6 +23,26 @@ const fallbackTop10: LeaderboardRow[] = [
{ player: 'Jules', score: 11, questions: 7, successRate: 70, durationSec: 1710 },
]
function toLeaderboardRow(raw: unknown): LeaderboardRow | null {
if (!raw || typeof raw !== 'object') return null
const item = raw as Record<string, unknown>
const player = (item.player ?? item.PlayerName) as string | undefined
const score = (item.score ?? item.Score) as number | undefined
const questions = (item.questions ?? item.QuestionsAsked) as number | undefined
const successRate = (item.successRate ?? item.SuccessRate) as number | undefined
const durationSec = (item.durationSec ?? item.DurationSeconds) as number | undefined
if (
typeof player !== 'string' ||
typeof score !== 'number' ||
typeof questions !== 'number' ||
typeof successRate !== 'number' ||
typeof durationSec !== 'number'
) {
return null
}
return { player, score, questions, successRate, durationSec }
}
function hasStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
@ -48,7 +68,28 @@ export const leaderboardClient = {
async top10(): Promise<LeaderboardRow[]> {
if (shouldUseMockLeaderboard()) return fallbackTop10
const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
return (resp.items ?? []).slice(0, 10)
const resp = await json<{
items?: LeaderboardRow[]
data?:
| LeaderboardRow[]
| {
items?: Array<{
entry?: unknown
}>
}
}>(`/leaderboard/top10`)
const flat = (resp.items ?? (Array.isArray(resp.data) ? resp.data : []))
.map((row) => toLeaderboardRow(row))
.filter((row): row is LeaderboardRow => row !== null)
if (flat.length > 0) return flat.slice(0, 10)
const rankedItems = Array.isArray((resp.data as { items?: unknown[] } | undefined)?.items)
? (resp.data as { items: Array<{ entry?: unknown }> }).items
: []
return rankedItems
.map((item) => toLeaderboardRow(item.entry))
.filter((row): row is LeaderboardRow => row !== null)
.slice(0, 10)
},
}

@ -14,6 +14,7 @@
"format:check": "prettier --check .",
"test": "yarn workspaces foreach -A run test",
"test:e2e": "playwright test -c apps/web/playwright.config.ts",
"test:e2e:fullstack": "playwright test -c apps/web/playwright.fullstack.config.ts",
"clean": "yarn workspaces foreach -A run clean"
},
"devDependencies": {

Loading…
Cancel
Save