diff --git a/src/frontend/e2e/smoke.spec.ts b/src/frontend/e2e/smoke.spec.ts new file mode 100644 index 0000000..7d60d7a --- /dev/null +++ b/src/frontend/e2e/smoke.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from '@playwright/test'; + +/** + * Smoke Tests E2E para CI/CD + * + * Estos tests verifican funcionalidades críticas contra el servidor real. + * Se ejecutan después del deploy para validar que el sistema funciona. + * + * Ejecutar: BASE_URL=https://academia.ingeniumcodex.com npx playwright test smoke.spec.ts + */ + +test.describe('Smoke Tests - Páginas Públicas', () => { + test('página de login carga correctamente', async ({ page }) => { + await page.goto('/login'); + + // Verificar que la página cargó + await expect(page).toHaveTitle(/estudiante|academia|login/i); + + // Verificar elementos básicos del formulario + await expect(page.locator('input[type="text"], input[name="username"]').first()).toBeVisible(); + await expect(page.locator('input[type="password"]').first()).toBeVisible(); + await expect(page.locator('button[type="submit"]').first()).toBeVisible(); + }); + + test('página de registro carga correctamente', async ({ page }) => { + await page.goto('/register'); + + // Verificar elementos del formulario de registro + await expect(page.locator('input').first()).toBeVisible(); + await expect(page.locator('button[type="submit"]').first()).toBeVisible(); + }); + + test('página de activación acepta código en URL', async ({ page }) => { + await page.goto('/activate?code=TEST123'); + + // Debe mostrar algún contenido (error o formulario) + await expect(page.locator('body')).not.toBeEmpty(); + }); + + test('redirección a login si no autenticado', async ({ page }) => { + // Limpiar storage + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + + // Intentar acceder a ruta protegida + await page.goto('/dashboard'); + + // Debe redirigir a login + await page.waitForURL(/\/login/, { timeout: 10000 }); + expect(page.url()).toContain('/login'); + }); +}); + +test.describe('Smoke Tests - API GraphQL', () => { + test('endpoint GraphQL responde', async ({ request, baseURL }) => { + const response = await request.post(`${baseURL?.replace(':4200', ':5000') || 'http://localhost:5000'}/graphql`, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ __typename }', + }, + }); + + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.data).toBeDefined(); + }); + + test('query de materias retorna datos', async ({ request, baseURL }) => { + const apiUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/graphql' + : baseURL?.replace(/\/$/, '') + '/graphql'; + + const response = await request.post(apiUrl, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ subjects { id name credits } }', + }, + }); + + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.data?.subjects).toBeDefined(); + expect(body.data.subjects.length).toBeGreaterThan(0); + }); + + test('query de profesores retorna datos', async ({ request, baseURL }) => { + const apiUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/graphql' + : baseURL?.replace(/\/$/, '') + '/graphql'; + + const response = await request.post(apiUrl, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ professors { id name } }', + }, + }); + + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.data?.professors).toBeDefined(); + expect(body.data.professors.length).toBe(5); // 5 profesores según reglas de negocio + }); +}); + +test.describe('Smoke Tests - Health Check', () => { + test('health endpoint responde healthy', async ({ request, baseURL }) => { + const healthUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/health' + : baseURL?.replace(/\/$/, '') + '/health'; + + const response = await request.get(healthUrl); + + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.status).toBe('Healthy'); + }); +}); + +test.describe('Smoke Tests - Validaciones de Negocio', () => { + test('debe existir exactamente 10 materias', async ({ request, baseURL }) => { + const apiUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/graphql' + : baseURL?.replace(/\/$/, '') + '/graphql'; + + const response = await request.post(apiUrl, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ subjects { id } }', + }, + }); + + const body = await response.json(); + expect(body.data.subjects.length).toBe(10); // 10 materias según reglas + }); + + test('cada materia debe tener 3 créditos', async ({ request, baseURL }) => { + const apiUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/graphql' + : baseURL?.replace(/\/$/, '') + '/graphql'; + + const response = await request.post(apiUrl, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ subjects { credits } }', + }, + }); + + const body = await response.json(); + const allThreeCredits = body.data.subjects.every((s: { credits: number }) => s.credits === 3); + expect(allThreeCredits).toBeTruthy(); + }); + + test('cada profesor debe tener exactamente 2 materias', async ({ request, baseURL }) => { + const apiUrl = baseURL?.includes('localhost:4200') + ? 'http://localhost:5000/graphql' + : baseURL?.replace(/\/$/, '') + '/graphql'; + + const response = await request.post(apiUrl, { + headers: { 'Content-Type': 'application/json' }, + data: { + query: '{ professors { id subjects { id } } }', + }, + }); + + const body = await response.json(); + const allTwoSubjects = body.data.professors.every( + (p: { subjects: unknown[] }) => p.subjects.length === 2 + ); + expect(allTwoSubjects).toBeTruthy(); + }); +}); diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 2695ecd..397ee6a 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -1,16 +1,23 @@ import { defineConfig, devices } from '@playwright/test'; +// URL base configurable via variable de entorno (para CI/CD) +const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; +const isCI = !!process.env['CI']; +const isProduction = baseURL.includes('https://'); + export default defineConfig({ testDir: './e2e', fullyParallel: true, - forbidOnly: !!process.env['CI'], - retries: process.env['CI'] ? 2 : 0, - workers: process.env['CI'] ? 1 : undefined, - reporter: 'html', + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: isCI ? [['html'], ['list']] : 'html', + timeout: isProduction ? 60000 : 30000, use: { - baseURL: 'http://localhost:4200', + baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', + video: isCI ? 'retain-on-failure' : 'off', }, projects: [ { @@ -18,10 +25,15 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - webServer: { - command: 'npm run start', - url: 'http://localhost:4200', - reuseExistingServer: !process.env['CI'], - timeout: 120000, - }, + // Solo iniciar servidor local si no es producción + ...(isProduction + ? {} + : { + webServer: { + command: 'npm run start', + url: 'http://localhost:4200', + reuseExistingServer: !isCI, + timeout: 120000, + }, + }), });