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