Added full end to end integration tests that exercises frontend, backend and persistence stores
parent
ef49b6ac63
commit
c2b81bdac7
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue