33 Commits

Author SHA1 Message Date
25ba33d43a Merge pull request 'Update jenkinsfile.ci' (#30) from feature/main-UpdateJenkinsFileCi into main
Reviewed-on: #30
2026-02-06 21:39:23 +01:00
896a7413fe Update Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 21:35:56 +01:00
1c572a30a0 Delete build from CD job 2026-02-06 21:34:34 +01:00
e5cd76c0ba Publish frontend tests
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 21:13:24 +01:00
484412b34e Generate junit xml in frontend tests 2026-02-06 21:11:28 +01:00
9409707329 Update ci msgs 2026-02-06 21:08:25 +01:00
aafc74d876 Delete illegal option
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:59:19 +01:00
408411117c Update shell
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:57:55 +01:00
22f19a2ced Update Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:54:14 +01:00
ac2174d365 Actualizar Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:45:24 +01:00
4ef74b38eb Actualizar Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:34:23 +01:00
214aec2e73 Actualizar Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:31:19 +01:00
f431f1c5dc Actualizar Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:27:14 +01:00
0550e4a431 Update jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions No test results found
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:22:25 +01:00
2264b76d9c Merge pull request 'feature/frontend-tests' (#26) from feature/frontend-tests into main
Reviewed-on: #26
Reviewed-by: Jose <husbando_enjoyer@openbokeron+gitea.noreply@geeklab.es>
2026-02-05 20:33:15 +01:00
Abdulee
4ab3ca0642 fix(test): reemplazar tests async fallidos por tests sincronos
All checks were successful
OB/TallerCiCd/pipeline/pr-main This commit looks good
CI-Multi/pipeline/pr-main This commit looks good
2026-02-04 13:48:38 +01:00
Abdulee
beeb58c07a fix(test): configurar jest-dom para tests de componente 2026-02-04 13:36:55 +01:00
Abdulee
5d1f518733 fix(test): eliminar test incorrecto de mayusculas 2026-02-04 13:36:49 +01:00
Abdulee
d2f16e65b2 chore: excluir TESTS.md del repositorio 2026-02-04 13:32:39 +01:00
Abdulee
0fe2569131 ci: ejecutar npm test en pipeline frontend 2026-02-04 13:20:59 +01:00
Abdulee
33a629f47d docs(frontend): añadir documentación de tests 2026-02-04 13:20:29 +01:00
Abdulee
e30ff9b178 test(frontend): añadir tests de componente App 2026-02-04 13:19:30 +01:00
Abdulee
79bfdf78c1 test(frontend): añadir tests de API con mocking 2026-02-04 13:18:36 +01:00
Abdulee
e40ca64536 test(frontend): añadir tests unitarios para prettify() 2026-02-04 13:16:28 +01:00
295accd825 Merge pull request 'Update CI job' (#20) from bugfix/main-11-WorkspaceCleanup into main
Reviewed-on: #20
2026-01-17 22:46:39 +01:00
87b846b4ae Add node
All checks were successful
CI-Multi/pipeline/pr-main This commit looks good
2026-01-17 22:09:31 +01:00
330c2a5364 Update CI job 2026-01-17 12:33:49 +01:00
512c1cea7b Merge pull request 'bugfix/main-16-PreventException' (#18) from bugfix/main-16-PreventException into main
Reviewed-on: #18
2025-12-28 11:35:40 +01:00
5845fed88f Ruff fix 2025-12-28 11:34:30 +01:00
938fd8170c Return error status 2025-12-28 11:34:01 +01:00
1f0c10b458 Merge pull request 'bugfix/main-13-UpdateConfigurations' (#17) from bugfix/main-13-UpdateConfigurations into main
Reviewed-on: #17
2025-12-28 11:15:36 +01:00
b15630c7ea Delete trigger 2025-12-28 11:05:24 +01:00
bc044a10c9 Change base images 2025-12-28 11:01:55 +01:00
15 changed files with 425 additions and 104 deletions

View File

@@ -34,42 +34,8 @@ pipeline {
PYTHONDONTWRITEBYTECODE = 1
}
triggers {
gitea(
branchFilterType: 'Include',
branchFilter: 'main',
secret: ''
)
}
stages {
/* =========================
TESTS
========================= */
stage('Backend: test (main)') {
agent {
docker {
image 'python:3.11-slim'
args '-u root'
}
}
steps {
dir('backend') {
sh '''
set -e
python -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements-dev.txt
pytest
'''
}
}
}
/* =========================
DOCKER BUILD
========================= */

View File

@@ -38,11 +38,12 @@ pipeline {
agent {
docker {
image 'python:3.11-slim'
args '-u root'
}
}
environment {
HOME = "${WORKSPACE}"
PIP_CACHE_DIR = "${WORKSPACE}/.cache/pip"
PYTHONDONTWRITEBYTECODE = 1
}
@@ -50,12 +51,13 @@ pipeline {
dir('backend') {
sh '''
set -e
mkdir -p "$PIP_CACHE_DIR" "$WORKSPACE/.cache/pytest"
python -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements-dev.txt
ruff check app tests
pytest
pytest -o cache_dir="$WORKSPACE/.cache/pytest" --junitxml=pytest.xml
'''
}
}
@@ -71,23 +73,95 @@ pipeline {
image 'node:20-slim'
}
}
environment {
HOME = "${WORKSPACE}"
NPM_CONFIG_CACHE = "${WORKSPACE}/.cache/npm"
}
steps {
dir('frontend') {
sh '''
set -e
mkdir -p "$NPM_CONFIG_CACHE"
npm install --no-progress --no-audit --prefer-offline
npm run check
mkdir -p test-results
npm test
npm run build
'''
}
}
}
stage('Cleanup') {
agent any
steps {
cleanWs()
}
post {
always {
script {
node {
junit testResults: 'backend/pytest.xml', allowEmptyResults: true
junit testResults: 'frontend/test-results/junit.xml', allowEmptyResults: true
if (env.CHANGE_ID) {
def giteaBase = 'https://openbokeron.org'
def owner = 'OpenBokeron'
def repo = 'TallerCiCd'
def pr = env.CHANGE_ID
def result = currentBuild.currentResult
def msg = ""
if (result == "SUCCESS") {
msg = """
✅ Todo en orden, camarada.
- URL: ${env.BUILD_URL}
""".stripIndent().trim()
}
else if (result == "FAILURE") {
msg = """
❌ Alto ahí. ¿Qué clase de crímenes de guerra has cometido en esta PR?
- URL: ${env.BUILD_URL}
""".stripIndent().trim()
}
else {
msg = """
⚠️ Dudoso…
- URL: ${env.BUILD_URL}
""".stripIndent().trim()
}
def commentsUrl = "${giteaBase}/gitea/api/v1/repos/${owner}/${repo}/issues/${pr}/comments"
withCredentials([usernamePassword(credentialsId: 'jenkins-bot-api',
usernameVariable: 'GITEA_USER',
passwordVariable: 'GITEA_TOKEN')]) {
def body = msg.replace('\\', '\\\\').replace('"','\\"').replace('\n','\\n')
// Avoid interpolation of secret variables (https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#string-interpolation)
withEnv(["GITEA_COMMENTS_URL=${commentsUrl}", "GITEA_BODY=${body}"]) {
sh(label: 'Comentar en PR (Gitea)', script: '''
curl -sS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- \
"$GITEA_COMMENTS_URL" <<JSON
{"body":"$GITEA_BODY"}
JSON
''')
}
}
} else {
echo "No es build de PR (CHANGE_ID vacío); no comento."
}
cleanWs()
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM docker.io/library/python:3.11-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -9,7 +9,7 @@ COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps -r requirements.txt -w /build/wheels
FROM python:3.11-slim
FROM docker.io/library/python:3.11-slim
WORKDIR /app
# ---- Build args (desde Jenkins) ----

View File

@@ -1,44 +0,0 @@
{
"builds": [
{
"number": 205,
"status": "success",
"branch": "main",
"commit": "9ac3f91",
"author": "Miau",
"finished_at": "2024-05-04T10:20:00Z",
"duration_seconds": 312
},
{
"number": 204,
"status": "failed",
"branch": "feature/nosetioestoesunmock",
"commit": "75c4ba2",
"author": "Miau",
"finished_at": "2024-05-04T09:50:00Z",
"duration_seconds": 188,
"failed_stage": "tests",
"fun_message": "woops"
},
{
"number": 203,
"status": "failed",
"branch": "main",
"commit": "512ca7e",
"author": "Miau",
"finished_at": "2024-05-04T09:10:00Z",
"duration_seconds": 140,
"failed_stage": "lint",
"fun_message": "Nadie pasa en local el linter"
},
{
"number": 202,
"status": "success",
"branch": "hotfix/tehedichoqueestoesunmock?",
"commit": "c73d8ab",
"author": "Miau",
"finished_at": "2024-05-03T18:30:00Z",
"duration_seconds": 276
}
]
}

View File

@@ -16,8 +16,10 @@
import time
from fastapi import FastAPI
import requests
from fastapi import FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.services.builds import build_history
from app.services.menu import build_menu
@@ -76,4 +78,10 @@ def price_for_item(item: str):
@app.get("/builds")
def builds():
return build_history()
try:
return build_history()
except (requests.RequestException, ValueError):
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"builds": [], "error": "jenkins_unavailable"},
)

View File

@@ -15,26 +15,17 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import base64
import json
from pathlib import Path
from typing import Dict, List
import requests
from app.settings import settings
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
def _sort_builds(builds: List[Dict]) -> List[Dict]:
return sorted(builds, key=lambda build: build.get("number", 0), reverse=True)
def normalize_build(build: Dict) -> Dict:
changes = build.get("changeSets", [])
commits = []
@@ -58,11 +49,16 @@ def normalize_build(build: Dict) -> Dict:
def _auth_header() -> Dict[str, str]:
if not settings.jenkins_user or not settings.jenkins_token:
return {}
token = f"{settings.jenkins_user}:{settings.jenkins_token}"
encoded = base64.b64encode(token.encode()).decode()
return {"Authorization": f"Basic {encoded}"}
def fetch_builds(limit: int = 5) -> List[Dict]:
if not settings.jenkins_job_name:
raise ValueError("JENKINS_JOB_NAME not configured")
url = (
f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json"
"?tree=builds[number,url,result,timestamp,duration,"
@@ -78,6 +74,4 @@ def fetch_builds(limit: int = 5) -> List[Dict]:
def build_history() -> Dict:
"""Return Jenkins build history data."""
builds = fetch_builds()
return {
"builds": [normalize_build(b) for b in builds]
}
return {"builds": [normalize_build(b) for b in builds]}

View File

@@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import requests
from fastapi.testclient import TestClient
from app.main import app
@@ -106,3 +107,16 @@ def test_build_history(monkeypatch):
assert second["status"] == "running"
assert second["duration_seconds"] == 1
assert second["commits"] == []
def test_build_history_error_returns_empty(monkeypatch):
def raise_error(limit=5):
raise requests.RequestException("boom")
monkeypatch.setattr("app.services.builds.fetch_builds", raise_error)
response = client.get("/builds")
assert response.status_code == 503
body = response.json()
assert body["builds"] == []
assert body["error"] == "jenkins_unavailable"

1
frontend/.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist
*.log
.svelte-kit
vite.config.ts.timestamp-*
TESTS.md

View File

@@ -1,4 +1,4 @@
FROM node:20-slim AS build
FROM docker.io/library/node:20-slim AS build
WORKDIR /app
COPY package*.json ./
ARG VITE_API_BASE
@@ -7,7 +7,7 @@ RUN npm install --no-progress
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS final
FROM docker.io/library/nginx:1.27-alpine AS final
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html/taller
EXPOSE 8081

View File

@@ -8,7 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check",
"test": "vitest"
"test": "vitest run --reporter=default --reporter=junit --outputFile=./test-results/junit.xml"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.1",

127
frontend/src/App.test.js Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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();
});
});
});

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
});

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
import '@testing-library/jest-dom';

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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('');
});
});

View File

@@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => {
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.js'],
},
server: {
port: 5173,