From 4198b91498741346108a231d6070b6db8984fff9 Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Mon, 23 Feb 2026 13:48:13 +0100 Subject: [PATCH] test(ui): add e2e and integration tests --- tests/e2e/admin.spec.js | 38 ++++ tests/e2e/cv.spec.js | 39 ++++ tests/integration/docker-compose.test.yml | 17 ++ tests/integration/fullstack.test.js | 205 ++++++++++++++++++ tests/performance/k6/load.js | 55 +++++ tests/performance/lighthouserc.js | 21 ++ .../__snapshots__/api-snapshot.test.js.snap | 109 ++++++++++ tests/regression/api-snapshot.test.js | 104 +++++++++ tests/regression/visual.test.js | 44 ++++ 9 files changed, 632 insertions(+) create mode 100644 tests/e2e/admin.spec.js create mode 100644 tests/e2e/cv.spec.js create mode 100644 tests/integration/docker-compose.test.yml create mode 100644 tests/integration/fullstack.test.js create mode 100644 tests/performance/k6/load.js create mode 100644 tests/performance/lighthouserc.js create mode 100644 tests/regression/__snapshots__/api-snapshot.test.js.snap create mode 100644 tests/regression/api-snapshot.test.js create mode 100644 tests/regression/visual.test.js diff --git a/tests/e2e/admin.spec.js b/tests/e2e/admin.spec.js new file mode 100644 index 0000000..d23797e --- /dev/null +++ b/tests/e2e/admin.spec.js @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Admin Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/admin'); + }); + + test('displays admin navigation', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'CV Admin' })).toBeVisible(); + }); + + test('navigation links work', async ({ page }) => { + await page.click('text=Erfahrung'); + await expect(page).toHaveURL(/.*experience/); + + await page.click('text=Skills'); + await expect(page).toHaveURL(/.*skills/); + + await page.click('text=Persönlich'); + await expect(page).toHaveURL(/.*admin$/); + }); + + test('personal form displays fields', async ({ page }) => { + const form = page.locator('form'); + await expect(form.locator('input[name="name"]')).toBeVisible(); + await expect(form.locator('input[name="title"]')).toBeVisible(); + await expect(form.locator('input[name="email"]')).toBeVisible(); + }); + + test('can navigate to experience section', async ({ page }) => { + await page.click('text=Erfahrung'); + await expect(page.getByRole('heading', { name: 'Berufserfahrung' })).toBeVisible(); + }); + + test('export button is present', async ({ page }) => { + await expect(page.getByRole('button', { name: /json export/i })).toBeVisible(); + }); +}); diff --git a/tests/e2e/cv.spec.js b/tests/e2e/cv.spec.js new file mode 100644 index 0000000..22206b3 --- /dev/null +++ b/tests/e2e/cv.spec.js @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; + +test.describe('CV Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('displays hero section with name', async ({ page }) => { + await expect(page.locator('h1')).toContainText('Tuan-Dat Tran'); + }); + + test('displays job title', async ({ page }) => { + const heroTitle = page.locator('section').first().locator('h2'); + await expect(heroTitle).toContainText('DevOps Engineer'); + }); + + test('displays experience section', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Berufserfahrung' })).toBeVisible(); + }); + + test('displays skills section', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Skills' })).toBeVisible(); + }); + + test('displays education section', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Ausbildung' })).toBeVisible(); + }); + + test('displays projects section', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Projekte' })).toBeVisible(); + }); + + test('contact links are present', async ({ page }) => { + const heroSection = page.locator('section').first(); + await expect(heroSection.getByRole('link', { name: 'Email' })).toBeVisible(); + await expect(heroSection.getByRole('link', { name: 'GitHub' })).toBeVisible(); + await expect(heroSection.getByRole('link', { name: 'LinkedIn' })).toBeVisible(); + }); +}); diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml new file mode 100644 index 0000000..6010834 --- /dev/null +++ b/tests/integration/docker-compose.test.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + backend-test: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3002:3001" + environment: + - PORT=3001 + - DB_PATH=/app/data/test.db + volumes: + - test-data:/app/data + +volumes: + test-data: diff --git a/tests/integration/fullstack.test.js b/tests/integration/fullstack.test.js new file mode 100644 index 0000000..4393771 --- /dev/null +++ b/tests/integration/fullstack.test.js @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import cors from 'cors'; +import { mkdirSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import Database from 'better-sqlite3'; + +const testDbPath = join(process.cwd(), 'tests', 'integration', 'test.db'); +let app; +let db; + +function setupTestDB() { + const dbDir = join(process.cwd(), 'tests', 'integration'); + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + if (existsSync(testDbPath)) { + rmSync(testDbPath); + } + + db = new Database(testDbPath); + + db.exec(` + CREATE TABLE IF NOT EXISTS cv_data ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + const defaultData = { + personal: { name: 'Test User', title: 'Developer', email: 'test@test.com' }, + experience: [], + skills: {}, + education: [], + projects: [] + }; + + db.prepare('INSERT INTO cv_data (id, data) VALUES (1, ?)').run(JSON.stringify(defaultData)); +} + +function createTestApp() { + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.get('/api/cv', (req, res) => { + const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get(); + if (!row) return res.status(404).json({ error: 'CV data not found' }); + res.json(JSON.parse(row.data)); + }); + + app.put('/api/cv', (req, res) => { + const data = req.body; + if (!data?.personal?.name) { + return res.status(400).json({ error: 'personal.name is required' }); + } + db.prepare('UPDATE cv_data SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1') + .run(JSON.stringify(data)); + res.json({ success: true, message: 'CV data updated' }); + }); + + app.get('/api/cv/export', (req, res) => { + const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename="cv.json"'); + res.send(row.data); + }); + + app.post('/api/cv/import', (req, res) => { + const data = req.body; + if (!data?.personal?.name) { + return res.status(400).json({ error: 'personal.name is required' }); + } + db.prepare('UPDATE cv_data SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1') + .run(JSON.stringify(data)); + res.json({ success: true, message: 'CV data imported' }); + }); + + app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + return app; +} + +describe('Integration: Full Stack CV API', () => { + beforeAll(() => { + setupTestDB(); + app = createTestApp(); + }); + + afterAll(() => { + if (db) { + db.close(); + } + if (existsSync(testDbPath)) { + rmSync(testDbPath); + } + }); + + describe('GET /api/cv', () => { + it('returns CV data from database', async () => { + const response = await request(app).get('/api/cv'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('personal'); + expect(response.body.personal.name).toBe('Test User'); + }); + + it('returns all required CV sections', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body).toHaveProperty('personal'); + expect(response.body).toHaveProperty('experience'); + expect(response.body).toHaveProperty('skills'); + expect(response.body).toHaveProperty('education'); + expect(response.body).toHaveProperty('projects'); + }); + }); + + describe('PUT /api/cv', () => { + it('updates CV data in database', async () => { + const newData = { + personal: { name: 'Updated Name', title: 'Senior Dev', email: 'updated@test.com' }, + experience: [{ id: 1, role: 'New Role' }], + skills: { 'Backend': ['Node.js'] }, + education: [], + projects: [] + }; + + const response = await request(app) + .put('/api/cv') + .send(newData); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + const verifyResponse = await request(app).get('/api/cv'); + expect(verifyResponse.body.personal.name).toBe('Updated Name'); + expect(verifyResponse.body.experience).toHaveLength(1); + }); + + it('validates required fields', async () => { + const response = await request(app) + .put('/api/cv') + .send({ personal: {} }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('name is required'); + }); + }); + + describe('GET /api/cv/export', () => { + it('exports CV as JSON file', async () => { + const response = await request(app).get('/api/cv/export'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.headers['content-disposition']).toContain('cv.json'); + + const data = JSON.parse(response.text); + expect(data).toHaveProperty('personal'); + }); + }); + + describe('POST /api/cv/import', () => { + it('imports valid CV data', async () => { + const importData = { + personal: { name: 'Imported User', title: 'Dev', email: 'import@test.com' }, + experience: [], + skills: {}, + education: [], + projects: [] + }; + + const response = await request(app) + .post('/api/cv/import') + .send(importData); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + const verifyResponse = await request(app).get('/api/cv'); + expect(verifyResponse.body.personal.name).toBe('Imported User'); + }); + + it('rejects invalid CV data', async () => { + const response = await request(app) + .post('/api/cv/import') + .send({ personal: { title: 'No Name' } }); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /health', () => { + it('returns health status', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); + expect(response.body).toHaveProperty('timestamp'); + }); + }); +}); diff --git a/tests/performance/k6/load.js b/tests/performance/k6/load.js new file mode 100644 index 0000000..1909c38 --- /dev/null +++ b/tests/performance/k6/load.js @@ -0,0 +1,55 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const responseTime = new Trend('response_time'); + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 100 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + errors: ['rate<0.1'], + http_req_duration: ['p(95)<500', 'p(99)<1000'], + response_time: ['p(95)<500'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; + +export default function () { + const response = http.get(`${BASE_URL}/api/cv`); + + check(response, { + 'status is 200': (r) => r.status === 200, + 'has personal data': (r) => { + const body = r.json(); + return body.personal && body.personal.name; + }, + 'has experience array': (r) => { + const body = r.json(); + return Array.isArray(body.experience); + }, + 'has skills object': (r) => { + const body = r.json(); + return body.skills && typeof body.skills === 'object'; + }, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); + + errorRate.add(response.status !== 200); + responseTime.add(response.timings.duration); + + sleep(1); +} + +export function handleSummary(data) { + return { + 'stdout': JSON.stringify(data, null, 2), + 'tests/performance/results.json': JSON.stringify(data, null, 2), + }; +} diff --git a/tests/performance/lighthouserc.js b/tests/performance/lighthouserc.js new file mode 100644 index 0000000..7698bbe --- /dev/null +++ b/tests/performance/lighthouserc.js @@ -0,0 +1,21 @@ +module.exports = { + ci: { + collect: { + numberOfRuns: 3, + settings: { + preset: 'desktop', + }, + }, + assert: { + assertions: { + 'categories:performance': ['warn', { minScore: 0.8 }], + 'categories:accessibility': ['error', { minScore: 0.9 }], + 'categories:best-practices': ['warn', { minScore: 0.8 }], + 'categories:seo': ['warn', { minScore: 0.8 }], + }, + }, + upload: { + target: 'temporary-public-storage', + }, + }, +}; diff --git a/tests/regression/__snapshots__/api-snapshot.test.js.snap b/tests/regression/__snapshots__/api-snapshot.test.js.snap new file mode 100644 index 0000000..8d8b9f4 --- /dev/null +++ b/tests/regression/__snapshots__/api-snapshot.test.js.snap @@ -0,0 +1,109 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Regression: API Snapshots > GET /api/cv response matches snapshot > cv-data-response 1`] = ` +{ + "education": [ + { + "degree": "CS Degree", + "id": 1, + "institution": "Test University", + "period": "2016-2020", + "status": "Completed", + }, + ], + "experience": [ + { + "company": "Test Corp", + "description": "Testing", + "highlights": [ + "Automated tests", + ], + "id": 1, + "period": "2020-2024", + "role": "QA Engineer", + "type": "Vollzeit", + }, + ], + "personal": { + "email": "regression@test.com", + "name": "Regression Test User", + "title": "Test Engineer", + }, + "projects": [ + { + "description": "Test framework", + "id": 1, + "name": "Test Project", + "tech": [ + "Vitest", + ], + "url": "https://test.com", + }, + ], + "skills": { + "Testing": [ + "Vitest", + "Playwright", + ], + }, +} +`; + +exports[`Regression: API Snapshots > education section matches snapshot > education-section 1`] = ` +[ + { + "degree": "CS Degree", + "id": 1, + "institution": "Test University", + "period": "2016-2020", + "status": "Completed", + }, +] +`; + +exports[`Regression: API Snapshots > experience section matches snapshot > experience-section 1`] = ` +[ + { + "company": "Test Corp", + "description": "Testing", + "highlights": [ + "Automated tests", + ], + "id": 1, + "period": "2020-2024", + "role": "QA Engineer", + "type": "Vollzeit", + }, +] +`; + +exports[`Regression: API Snapshots > personal section matches snapshot > personal-section 1`] = ` +{ + "email": "regression@test.com", + "name": "Regression Test User", + "title": "Test Engineer", +} +`; + +exports[`Regression: API Snapshots > projects section matches snapshot > projects-section 1`] = ` +[ + { + "description": "Test framework", + "id": 1, + "name": "Test Project", + "tech": [ + "Vitest", + ], + "url": "https://test.com", + }, +] +`; + +exports[`Regression: API Snapshots > skills section matches snapshot > skills-section 1`] = ` +{ + "Testing": [ + "Vitest", + "Playwright", + ], +} +`; diff --git a/tests/regression/api-snapshot.test.js b/tests/regression/api-snapshot.test.js new file mode 100644 index 0000000..e5180f9 --- /dev/null +++ b/tests/regression/api-snapshot.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import cors from 'cors'; +import { mkdirSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import Database from 'better-sqlite3'; + +const testDbPath = join(process.cwd(), 'tests', 'regression', 'api-snapshots', 'test.db'); +const snapshotDir = join(process.cwd(), 'tests', 'regression', 'api-snapshots'); +let app; +let db; + +function setupTestDB() { + if (!existsSync(snapshotDir)) { + mkdirSync(snapshotDir, { recursive: true }); + } + + if (existsSync(testDbPath)) { + rmSync(testDbPath); + } + + db = new Database(testDbPath); + + db.exec(` + CREATE TABLE IF NOT EXISTS cv_data ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + const defaultData = { + personal: { name: 'Regression Test User', title: 'Test Engineer', email: 'regression@test.com' }, + experience: [{ id: 1, role: 'QA Engineer', company: 'Test Corp', period: '2020-2024', type: 'Vollzeit', description: 'Testing', highlights: ['Automated tests'] }], + skills: { 'Testing': ['Vitest', 'Playwright'] }, + education: [{ id: 1, degree: 'CS Degree', institution: 'Test University', period: '2016-2020', status: 'Completed' }], + projects: [{ id: 1, name: 'Test Project', description: 'Test framework', url: 'https://test.com', tech: ['Vitest'] }] + }; + + db.prepare('INSERT INTO cv_data (id, data) VALUES (1, ?)').run(JSON.stringify(defaultData)); +} + +function createTestApp() { + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.get('/api/cv', (req, res) => { + const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get(); + if (!row) return res.status(404).json({ error: 'CV data not found' }); + res.json(JSON.parse(row.data)); + }); + + return app; +} + +describe('Regression: API Snapshots', () => { + beforeAll(() => { + setupTestDB(); + app = createTestApp(); + }); + + afterAll(() => { + if (db) { + db.close(); + } + if (existsSync(testDbPath)) { + rmSync(testDbPath); + } + }); + + it('GET /api/cv response matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.status).toBe(200); + + expect(response.body).toMatchSnapshot('cv-data-response'); + }); + + it('personal section matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body.personal).toMatchSnapshot('personal-section'); + }); + + it('experience section matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body.experience).toMatchSnapshot('experience-section'); + }); + + it('skills section matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body.skills).toMatchSnapshot('skills-section'); + }); + + it('education section matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body.education).toMatchSnapshot('education-section'); + }); + + it('projects section matches snapshot', async () => { + const response = await request(app).get('/api/cv'); + expect(response.body.projects).toMatchSnapshot('projects-section'); + }); +}); diff --git a/tests/regression/visual.test.js b/tests/regression/visual.test.js new file mode 100644 index 0000000..1c6f2aa --- /dev/null +++ b/tests/regression/visual.test.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Visual Regression: CV Page', () => { + test('hero section visual snapshot', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const hero = page.locator('section').first(); + await expect(hero).toHaveScreenshot('hero-section.png', { + maxDiffPixels: 100, + }); + }); + + test('full page visual snapshot', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('full-page.png', { + fullPage: true, + maxDiffPixels: 500, + }); + }); +}); + +test.describe('Visual Regression: Admin Page', () => { + test('admin panel visual snapshot', async ({ page }) => { + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('admin-panel.png', { + maxDiffPixels: 200, + }); + }); + + test('personal form visual snapshot', async ({ page }) => { + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + + const form = page.locator('form').first(); + await expect(form).toHaveScreenshot('personal-form.png', { + maxDiffPixels: 100, + }); + }); +});