From c2b81bdac762589bf29bdceba9b91846b5a461fd Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 14 Feb 2026 12:47:34 +0100 Subject: [PATCH] Added full end to end integration tests that exercises frontend, backend and persistence stores --- Makefile | 7 +- .../infra/persistence/ent/leaderboard_repo.go | 2 +- .../4_work_plan/e2e_integration_tests_plan.md | 61 ++++++++++ .../admin-questions.integration.spec.ts | 63 ++++++++++ .../apps/web/e2e/full-stack/global.setup.ts | 7 ++ .../web/e2e/full-stack/global.teardown.ts | 7 ++ .../apps/web/e2e/full-stack/helpers/stack.ts | 110 ++++++++++++++++++ .../leaderboard.integration.spec.ts | 52 +++++++++ .../question-bank.integration.spec.ts | 42 +++++++ .../node_modules/.vite/vitest/results.json | 2 +- .../apps/web/playwright.fullstack.config.ts | 31 +++++ frontend/apps/web/src/services/api.ts | 45 ++++++- frontend/package.json | 1 + 13 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 docs/4_work_plan/e2e_integration_tests_plan.md create mode 100644 frontend/apps/web/e2e/full-stack/admin-questions.integration.spec.ts create mode 100644 frontend/apps/web/e2e/full-stack/global.setup.ts create mode 100644 frontend/apps/web/e2e/full-stack/global.teardown.ts create mode 100644 frontend/apps/web/e2e/full-stack/helpers/stack.ts create mode 100644 frontend/apps/web/e2e/full-stack/leaderboard.integration.spec.ts create mode 100644 frontend/apps/web/e2e/full-stack/question-bank.integration.spec.ts create mode 100644 frontend/apps/web/playwright.fullstack.config.ts diff --git a/Makefile b/Makefile index a92128b..08a9312 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go b/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go index 849289b..ed75b28 100644 --- a/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go +++ b/backend/services/leaderboard-service/internal/infra/persistence/ent/leaderboard_repo.go @@ -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 diff --git a/docs/4_work_plan/e2e_integration_tests_plan.md b/docs/4_work_plan/e2e_integration_tests_plan.md new file mode 100644 index 0000000..e3e1731 --- /dev/null +++ b/docs/4_work_plan/e2e_integration_tests_plan.md @@ -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 + diff --git a/frontend/apps/web/e2e/full-stack/admin-questions.integration.spec.ts b/frontend/apps/web/e2e/full-stack/admin-questions.integration.spec.ts new file mode 100644 index 0000000..6893d64 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/admin-questions.integration.spec.ts @@ -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') + }) +}) diff --git a/frontend/apps/web/e2e/full-stack/global.setup.ts b/frontend/apps/web/e2e/full-stack/global.setup.ts new file mode 100644 index 0000000..0867076 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/global.setup.ts @@ -0,0 +1,7 @@ +import type { FullConfig } from '@playwright/test' + +import { bringUpFullStack } from './helpers/stack' + +export default async function globalSetup(_config: FullConfig): Promise { + await bringUpFullStack() +} diff --git a/frontend/apps/web/e2e/full-stack/global.teardown.ts b/frontend/apps/web/e2e/full-stack/global.teardown.ts new file mode 100644 index 0000000..4d2aec9 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/global.teardown.ts @@ -0,0 +1,7 @@ +import type { FullConfig } from '@playwright/test' + +import { tearDownFullStack } from './helpers/stack' + +export default function globalTeardown(_config: FullConfig): void { + tearDownFullStack() +} diff --git a/frontend/apps/web/e2e/full-stack/helpers/stack.ts b/frontend/apps/web/e2e/full-stack/helpers/stack.ts new file mode 100644 index 0000000..89ed7f5 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/helpers/stack.ts @@ -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 { + 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 { + 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']) +} diff --git a/frontend/apps/web/e2e/full-stack/leaderboard.integration.spec.ts b/frontend/apps/web/e2e/full-stack/leaderboard.integration.spec.ts new file mode 100644 index 0000000..c24ddc5 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/leaderboard.integration.spec.ts @@ -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() + }) +}) diff --git a/frontend/apps/web/e2e/full-stack/question-bank.integration.spec.ts b/frontend/apps/web/e2e/full-stack/question-bank.integration.spec.ts new file mode 100644 index 0000000..f049c6f --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/question-bank.integration.spec.ts @@ -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) + }) +}) diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json index 113a8cf..8676962 100644 --- a/frontend/apps/web/node_modules/.vite/vitest/results.json +++ b/frontend/apps/web/node_modules/.vite/vitest/results.json @@ -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}]]} \ No newline at end of file +{"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}]]} \ No newline at end of file diff --git a/frontend/apps/web/playwright.fullstack.config.ts b/frontend/apps/web/playwright.fullstack.config.ts new file mode 100644 index 0000000..aa3e484 --- /dev/null +++ b/frontend/apps/web/playwright.fullstack.config.ts @@ -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', + }, + }, +}) diff --git a/frontend/apps/web/src/services/api.ts b/frontend/apps/web/src/services/api.ts index 460c23e..ef2ec77 100644 --- a/frontend/apps/web/src/services/api.ts +++ b/frontend/apps/web/src/services/api.ts @@ -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 + 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 { 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) }, } diff --git a/frontend/package.json b/frontend/package.json index f5c93bc..23ca8d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": {