diff --git a/Jenkinsfile.ci b/Jenkinsfile.ci
index 2de7f6a..51612d5 100644
--- a/Jenkinsfile.ci
+++ b/Jenkinsfile.ci
@@ -84,6 +84,7 @@ pipeline {
mkdir -p "$NPM_CONFIG_CACHE"
npm install --no-progress --no-audit --prefer-offline
npm run check
+ npm test
npm run build
'''
}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 4346d24..0152d07 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -6,3 +6,4 @@ dist
*.log
.svelte-kit
vite.config.ts.timestamp-*
+TESTS.md
diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js
new file mode 100644
index 0000000..91aa941
--- /dev/null
+++ b/frontend/src/App.test.js
@@ -0,0 +1,127 @@
+/*
+CI/CD Workshop
+Copyright (C) 2025 OpenBokeron
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/svelte';
+import App from './App.svelte';
+
+const mockMenu = {
+ starters: ['Sopa de cocido', 'Ensalada mixta'],
+ mains: ['Pescado al vapor', 'Pollo asado'],
+ garnish: ['Patatas fritas'],
+ desserts: ['Fruta del tiempo', 'Yogur'],
+ notes: ['Pan y bebida incluidos'],
+ menu_price: 5.5,
+ university_deal: {
+ old_price: 4.5,
+ current_price: 5.5,
+ note: 'Gracias al convenio con la Universidad',
+ },
+ alternative: { title: 'Plato alternativo', items: [], price: 5.0 },
+ availability: { last_updated: '12:00' },
+ espetos_tip: 'Hoy toca sardinas',
+};
+
+const mockPrices = {
+ items: [
+ { item: 'cafe', price: 1.2, currency: 'EUR', generated_at: '10:00' },
+ { item: 'tostada', price: 2.0, currency: 'EUR', generated_at: '10:00' },
+ ],
+};
+
+const mockHealth = {
+ status: 'ok',
+ build: 42,
+ commit: 'abc1234def5678',
+ author: 'Test Author',
+ uptime_seconds: 3600,
+};
+
+const mockBuilds = {
+ builds: [
+ {
+ number: 42,
+ status: 'success',
+ finished_at: Date.now(),
+ duration_seconds: 60,
+ commits: [{ commit: 'abc1234', message: 'Fix bug', author: 'Dev' }],
+ },
+ ],
+};
+
+function createMockFetch(url) {
+ if (url.includes('/menu')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMenu) });
+ }
+ if (url.includes('/prices')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockPrices) });
+ }
+ if (url.includes('/health')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockHealth) });
+ }
+ if (url.includes('/builds')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockBuilds) });
+ }
+ return Promise.resolve({ ok: false, status: 404 });
+}
+
+describe('App.svelte', () => {
+ beforeEach(() => {
+ globalThis.fetch = vi.fn(createMockFetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renderiza el titulo principal', async () => {
+ render(App);
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
+ });
+ });
+
+ it('renderiza la seccion hero con eyebrow', async () => {
+ render(App);
+
+ await waitFor(() => {
+ expect(screen.getByText('Taller CI/CD con Jenkins')).toBeInTheDocument();
+ });
+ });
+
+ it('renderiza los botones de accion', async () => {
+ render(App);
+
+ await waitFor(() => {
+ expect(screen.getByText('Refrescar menú')).toBeInTheDocument();
+ expect(screen.getByText('Recalcular desayunos')).toBeInTheDocument();
+ });
+ });
+
+ it('renderiza la tarjeta de Open Bokeron', async () => {
+ render(App);
+
+ await waitFor(() => {
+ expect(screen.getByText('Open Bokeron')).toBeInTheDocument();
+ expect(
+ screen.getByText('Somos Open Bokeron, la asociación de software libre de la ETSII.')
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js
new file mode 100644
index 0000000..d5c67b0
--- /dev/null
+++ b/frontend/src/services/api.test.js
@@ -0,0 +1,123 @@
+/*
+CI/CD Workshop
+Copyright (C) 2025 OpenBokeron
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { getMenu, getPrices, getCiStatus, getBuildHistory } from './api';
+
+describe('API services', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe('getMenu', () => {
+ it('devuelve datos del menu cuando la API responde correctamente', async () => {
+ const mockMenu = {
+ starters: ['Sopa'],
+ mains: ['Pescado al vapor'],
+ desserts: ['Fruta'],
+ };
+
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockMenu),
+ });
+
+ const result = await getMenu();
+
+ expect(fetch).toHaveBeenCalledWith('/taller/api/menu');
+ expect(result).toEqual(mockMenu);
+ });
+
+ it('lanza error cuando la API falla', async () => {
+ fetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ await expect(getMenu()).rejects.toThrow('Respuesta no valida del servidor');
+ });
+ });
+
+ describe('getPrices', () => {
+ it('devuelve array de items cuando la API responde', async () => {
+ const mockResponse = {
+ items: [
+ { item: 'cafe', price: 1.2 },
+ { item: 'tostada', price: 2.0 },
+ ],
+ };
+
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockResponse),
+ });
+
+ const result = await getPrices();
+
+ expect(fetch).toHaveBeenCalledWith('/taller/api/prices');
+ expect(result).toEqual(mockResponse.items);
+ });
+
+ it('devuelve array vacio si no hay items', async () => {
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const result = await getPrices();
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getCiStatus', () => {
+ it('llama al endpoint /health', async () => {
+ const mockHealth = { status: 'ok', build: 42 };
+
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockHealth),
+ });
+
+ const result = await getCiStatus();
+
+ expect(fetch).toHaveBeenCalledWith('/taller/api/health');
+ expect(result).toEqual(mockHealth);
+ });
+ });
+
+ describe('getBuildHistory', () => {
+ it('llama al endpoint /builds', async () => {
+ const mockBuilds = { builds: [{ number: 1 }, { number: 2 }] };
+
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockBuilds),
+ });
+
+ const result = await getBuildHistory();
+
+ expect(fetch).toHaveBeenCalledWith('/taller/api/builds');
+ expect(result).toEqual(mockBuilds);
+ });
+ });
+});
diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js
new file mode 100644
index 0000000..e3b07e8
--- /dev/null
+++ b/frontend/src/setupTests.js
@@ -0,0 +1,19 @@
+/*
+CI/CD Workshop
+Copyright (C) 2025 OpenBokeron
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import '@testing-library/jest-dom';
diff --git a/frontend/src/utils/text.test.js b/frontend/src/utils/text.test.js
new file mode 100644
index 0000000..afe9ee6
--- /dev/null
+++ b/frontend/src/utils/text.test.js
@@ -0,0 +1,38 @@
+/*
+CI/CD Workshop
+Copyright (C) 2025 OpenBokeron
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { describe, it, expect } from 'vitest';
+import { prettify } from './text';
+
+describe('prettify', () => {
+ it('convierte snake_case a Title Case', () => {
+ expect(prettify('hello_world')).toBe('Hello World');
+ });
+
+ it('maneja palabras multiples con underscore', () => {
+ expect(prettify('cafe_con_leche')).toBe('Cafe Con Leche');
+ });
+
+ it('maneja una sola palabra sin underscore', () => {
+ expect(prettify('bocadillo')).toBe('Bocadillo');
+ });
+
+ it('maneja cadena vacia', () => {
+ expect(prettify('')).toBe('');
+ });
+});
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index d8aba27..aab9f54 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => {
test: {
environment: 'jsdom',
globals: true,
+ setupFiles: ['./src/setupTests.js'],
},
server: {
port: 5173,