Compare commits
40 Commits
1f0c10b458
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6522452d | |||
| d367abe451 | |||
| f5910f763b | |||
| f2da39f0db | |||
| 84f4d2eef8 | |||
| 9297bed50e | |||
| 6660035fa3 | |||
| bd769b0eb0 | |||
| f9185e0f21 | |||
| 3556ac3d76 | |||
| 25ba33d43a | |||
| 896a7413fe | |||
| 1c572a30a0 | |||
| e5cd76c0ba | |||
| 484412b34e | |||
| 9409707329 | |||
| aafc74d876 | |||
| 408411117c | |||
| 22f19a2ced | |||
| ac2174d365 | |||
| 4ef74b38eb | |||
| 214aec2e73 | |||
| f431f1c5dc | |||
| 0550e4a431 | |||
| 2264b76d9c | |||
|
|
4ab3ca0642 | ||
|
|
beeb58c07a | ||
|
|
5d1f518733 | ||
|
|
d2f16e65b2 | ||
|
|
0fe2569131 | ||
|
|
33a629f47d | ||
|
|
e30ff9b178 | ||
|
|
79bfdf78c1 | ||
|
|
e40ca64536 | ||
| 295accd825 | |||
| 87b846b4ae | |||
| 330c2a5364 | |||
| 512c1cea7b | |||
| 5845fed88f | |||
| 938fd8170c |
@@ -9,7 +9,7 @@ pipeline {
|
|||||||
parameters {
|
parameters {
|
||||||
string(
|
string(
|
||||||
name: 'JENKINS_BASE_URL',
|
name: 'JENKINS_BASE_URL',
|
||||||
defaultValue: 'https://openbokeron.org/jenkins',
|
defaultValue: 'https://openbokeron.uma.es/jenkins',
|
||||||
description: 'Base URL del Jenkins objetivo'
|
description: 'Base URL del Jenkins objetivo'
|
||||||
)
|
)
|
||||||
string(
|
string(
|
||||||
@@ -36,32 +36,6 @@ pipeline {
|
|||||||
|
|
||||||
stages {
|
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
|
DOCKER BUILD
|
||||||
========================= */
|
========================= */
|
||||||
@@ -100,6 +74,22 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
TRIVY
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
stage('Security: Trivy job') {
|
||||||
|
agent any
|
||||||
|
steps {
|
||||||
|
build job: 'Trivy Scanner',
|
||||||
|
parameters: [
|
||||||
|
string(name: 'APP_VERSION', value: "${APP_VERSION}")
|
||||||
|
],
|
||||||
|
propagate: true,
|
||||||
|
wait: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
DEPLOY
|
DEPLOY
|
||||||
========================= */
|
========================= */
|
||||||
|
|||||||
@@ -38,11 +38,12 @@ pipeline {
|
|||||||
agent {
|
agent {
|
||||||
docker {
|
docker {
|
||||||
image 'python:3.11-slim'
|
image 'python:3.11-slim'
|
||||||
args '-u root'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
|
HOME = "${WORKSPACE}"
|
||||||
|
PIP_CACHE_DIR = "${WORKSPACE}/.cache/pip"
|
||||||
PYTHONDONTWRITEBYTECODE = 1
|
PYTHONDONTWRITEBYTECODE = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +51,13 @@ pipeline {
|
|||||||
dir('backend') {
|
dir('backend') {
|
||||||
sh '''
|
sh '''
|
||||||
set -e
|
set -e
|
||||||
|
mkdir -p "$PIP_CACHE_DIR" "$WORKSPACE/.cache/pytest"
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
ruff check app tests
|
ruff check app tests
|
||||||
pytest
|
pytest -o cache_dir="$WORKSPACE/.cache/pytest" --junitxml=pytest.xml
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,23 +73,95 @@ pipeline {
|
|||||||
image 'node:20-slim'
|
image 'node:20-slim'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
environment {
|
||||||
|
HOME = "${WORKSPACE}"
|
||||||
|
NPM_CONFIG_CACHE = "${WORKSPACE}/.cache/npm"
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
dir('frontend') {
|
dir('frontend') {
|
||||||
sh '''
|
sh '''
|
||||||
set -e
|
set -e
|
||||||
|
mkdir -p "$NPM_CONFIG_CACHE"
|
||||||
npm install --no-progress --no-audit --prefer-offline
|
npm install --no-progress --no-audit --prefer-offline
|
||||||
npm run check
|
npm run check
|
||||||
|
mkdir -p test-results
|
||||||
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Cleanup') {
|
}
|
||||||
agent any
|
|
||||||
steps {
|
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.uma.es'
|
||||||
|
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 = """
|
||||||
|
⚠️ Algo ha pasado, no sé si es culpa tuyao mía. Por probabilidad, pensaré que la has pifiado tú.
|
||||||
|
|
||||||
|
- 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()
|
cleanWs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -4,8 +4,11 @@
|
|||||||
- `frontend/`: interfaz en Svelte (Vite).
|
- `frontend/`: interfaz en Svelte (Vite).
|
||||||
|
|
||||||
## Requisitos locales
|
## Requisitos locales
|
||||||
- Python 3.11+ y `pip`
|
- Ubuntu/Debian: `python3`, `python3-venv` y `python3-pip`
|
||||||
- Node 18+ y `npm`
|
- Node 18+ y `npm`
|
||||||
|
- Docker y Docker Compose (solo para la opcion con contenedores)
|
||||||
|
|
||||||
|
En muchas distros `python` no existe o apunta a otra versión. Para evitar problemas, en esta guia se usa siempre `python3`.
|
||||||
|
|
||||||
## Configuracion de entorno (local/prod)
|
## Configuracion de entorno (local/prod)
|
||||||
Variables que usa `docker-compose` y el frontend:
|
Variables que usa `docker-compose` y el frontend:
|
||||||
@@ -14,59 +17,107 @@ cp .env.example .env
|
|||||||
```
|
```
|
||||||
Notas:
|
Notas:
|
||||||
- `VITE_API_BASE` por defecto apunta a `/taller/api` y el frontend proxya a la API.
|
- `VITE_API_BASE` por defecto apunta a `/taller/api` y el frontend proxya a la API.
|
||||||
- Para Jenkins local en contenedor: `JENKINS_BASE_URL=http://jenkins:8080`. Se hace necesario que back y jenkins estén en la misma red de Docker si se quiere probar en local.
|
- El archivo `.env` se usa para el despliegue con `docker compose` y para inyectar variables en las imágenes/contenedores. No hace falta para arrancar backend y frontend a mano en local.
|
||||||
- Para VPS: `JENKINS_BASE_URL=https://openbokeron.org/jenkins`.
|
- `JENKINS_BASE_URL` no suele funcionar en local tal como viene en el ejemplo. Esa variable está pensada para entornos desplegados, donde se sustituye por la URL pública real del Jenkins accesible desde el servidor o VPS.
|
||||||
|
- Si se quisiera probar Jenkins también en local con contenedores, backend y Jenkins tendrían que compartir red Docker y usar una URL interna como `http://jenkins:8080`.
|
||||||
|
- Para tener Jenkins local hay dos opciones sencillas: levantar el contenedor incluido en `jenkins/jenkins-compose.yml` o instalar Jenkins directamente en la máquina host.
|
||||||
|
- La opcion con contenedor puede dar guerra porque aquí se trabaja con un esquema tipo Docker-outside-of-Docker. Si da problemas, suele ser más simple instalar Jenkins en la máquina anfitriona y ajustar `JENKINS_BASE_URL` en el `.env` a esa URL local.
|
||||||
|
|
||||||
## Backend (FastAPI)
|
## Arranque local sin Docker
|
||||||
|
|
||||||
|
Abre dos terminales: una para el backend y otra para el frontend.
|
||||||
|
|
||||||
|
### Backend (FastAPI)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
python3 -m pip install -r requirements.txt
|
||||||
uvicorn app.main:app --reload --port 8000
|
python3 -m uvicorn app.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Si todo va bien, la API queda disponible en `http://127.0.0.1:8000`.
|
||||||
|
|
||||||
|
Importante: deja esa terminal abierta y con el entorno virtual activado mientras trabajas con el backend.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
- `GET /health` estado.
|
- `GET /health` estado.
|
||||||
- `GET /menu` devuelve el menú del día.
|
- `GET /menu` devuelve el menú del día.
|
||||||
- `GET /prices` lista de precios aleatorios.
|
- `GET /prices` lista de precios aleatorios.
|
||||||
- `GET /prices/{item}` precio aleatorio para un item concreto.
|
- `GET /prices/{item}` precio aleatorio para un item concreto.
|
||||||
|
|
||||||
Tests y lint:
|
Tests y lint del backend:
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements-dev.txt
|
source .venv/bin/activate
|
||||||
|
python3 -m pip install -r requirements-dev.txt
|
||||||
pytest
|
pytest
|
||||||
ruff check app tests
|
ruff check app tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker:
|
Si abres otra terminal, tienes que volver a activar el entorno virtual con `source .venv/bin/activate` antes de lanzar `pytest`, `ruff` o cualquier comando del backend.
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
docker build -t cafeteria-backend .
|
|
||||||
docker run -p 8000:8000 cafeteria-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend (Svelte)
|
### Frontend (Svelte)
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev -- --host --port 5173
|
npm run dev -- --host --port 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Abrir `http://localhost:5173/taller/`.
|
Abrir `http://localhost:5173/taller/`.
|
||||||
Tests, lint/check:
|
|
||||||
|
Tests y check del frontend:
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm test # no, no voy a hacer tests de frontend
|
npm test
|
||||||
npm run check
|
npm run check
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker (sirve con nginx):
|
## Arranque con Docker Compose
|
||||||
|
|
||||||
|
Desde la raíz del proyecto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Servicios disponibles:
|
||||||
|
- Frontend: `http://localhost:8081/taller/`
|
||||||
|
- Backend: `http://localhost:8000/health`
|
||||||
|
|
||||||
|
Para parar los contenedores:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Si quieres reconstruir imágenes despues de cambios grandes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arranque con Docker manual (sin Compose)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
docker build -t cafeteria-backend .
|
||||||
|
docker run --rm -p 8000:8000 cafeteria-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
docker build -t cafeteria-frontend .
|
docker build -t cafeteria-frontend .
|
||||||
docker run -p 8080:80 cafeteria-frontend
|
docker run --rm -p 8081:8081 cafeteria-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Abrir `http://localhost:8081/taller/`.
|
||||||
|
|
||||||
|
Nota: el contenedor del frontend está preparado para funcionar bien junto a `docker compose`, donde puede resolver el servicio `backend` por red interna. Si lanzas ambos contenedores manualmente por separado, tendrás que conectarlos a una red común y ajustar la resolución entre ellos.
|
||||||
|
|
||||||
|
|
||||||
## Jenkinsfile (pipeline)
|
## Jenkinsfile (pipeline)
|
||||||
Incluido `Jenkinsfile` declarativo para ejecutar en un agente cualquiera con Python 3.11+, Node 18+ y Docker:
|
Incluido `Jenkinsfile` declarativo para ejecutar en un agente cualquiera con Python 3.11+, Node 18+ y Docker:
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import FastAPI
|
import requests
|
||||||
|
from fastapi import FastAPI, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.services.builds import build_history
|
from app.services.builds import build_history
|
||||||
from app.services.menu import build_menu
|
from app.services.menu import build_menu
|
||||||
@@ -55,7 +57,7 @@ def health():
|
|||||||
"commit": settings.git_commit,
|
"commit": settings.git_commit,
|
||||||
"build": settings.build_number,
|
"build": settings.build_number,
|
||||||
"author": settings.commit_author,
|
"author": settings.commit_author,
|
||||||
"uptime_seconds": uptime()
|
"uptime_seconds": uptime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -76,4 +78,10 @@ def price_for_item(item: str):
|
|||||||
|
|
||||||
@app.get("/builds")
|
@app.get("/builds")
|
||||||
def builds():
|
def builds():
|
||||||
|
try:
|
||||||
return build_history()
|
return build_history()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
content={"builds": [], "error": "jenkins_unavailable"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,69 +15,116 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import html
|
||||||
from pathlib import Path
|
import re
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app.settings import settings
|
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]:
|
def _sort_builds(builds: List[Dict]) -> List[Dict]:
|
||||||
return sorted(builds, key=lambda build: build.get("number", 0), reverse=True)
|
return sorted(builds, key=lambda build: build.get("number", 0), reverse=True)
|
||||||
|
|
||||||
def normalize_build(build: Dict) -> Dict:
|
|
||||||
changes = build.get("changeSets", [])
|
|
||||||
commits = []
|
|
||||||
|
|
||||||
for cs in changes:
|
def _extract_commits(build: Dict) -> List[Dict]:
|
||||||
|
commits = []
|
||||||
|
for cs in build.get("changeSets", []):
|
||||||
for item in cs.get("items", []):
|
for item in cs.get("items", []):
|
||||||
commits.append({
|
commits.append(
|
||||||
|
{
|
||||||
"commit": item.get("commitId", "")[:7],
|
"commit": item.get("commitId", "")[:7],
|
||||||
"message": item.get("msg", ""),
|
"message": item.get("msg", ""),
|
||||||
"author": item.get("author", {}).get("fullName", "unknown"),
|
"author": item.get("author", {}).get("fullName", "unknown"),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
return commits
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_trigger(build: Dict) -> str:
|
||||||
|
for action in build.get("actions", []):
|
||||||
|
for cause in action.get("causes", []):
|
||||||
|
description = cause.get("shortDescription")
|
||||||
|
if description:
|
||||||
|
return html.unescape(description).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_trigger(trigger: str) -> Tuple[str, str, str]:
|
||||||
|
if not trigger:
|
||||||
|
return "", "", ""
|
||||||
|
|
||||||
|
pr_url = ""
|
||||||
|
for line in trigger.splitlines():
|
||||||
|
if line.strip().lower().startswith("reviewed-on:"):
|
||||||
|
pr_url = line.split(":", 1)[-1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
author_match = re.search(r"\(([^)]+)\)\.", trigger)
|
||||||
|
author = author_match.group(1).strip() if author_match else ""
|
||||||
|
|
||||||
|
pr_match = re.search(r"#(\d+)", trigger)
|
||||||
|
title_match = re.search(r"Merge pull request '([^']+)'", trigger)
|
||||||
|
|
||||||
|
if pr_match:
|
||||||
|
pr_number = pr_match.group(1)
|
||||||
|
title = title_match.group(1).strip() if title_match else ""
|
||||||
|
if title:
|
||||||
|
return f"Merge PR #{pr_number} · {title}", pr_url, author
|
||||||
|
return f"Merge PR #{pr_number}", pr_url, author
|
||||||
|
|
||||||
|
return trigger.splitlines()[0].strip(), pr_url, author
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_build(build: Dict) -> Dict:
|
||||||
|
commits = _extract_commits(build)
|
||||||
|
trigger = _extract_trigger(build)
|
||||||
|
trigger_label, trigger_url, trigger_author = _summarize_trigger(trigger)
|
||||||
|
status = (build.get("result") or "RUNNING").lower()
|
||||||
|
if build.get("building"):
|
||||||
|
status = "running"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"number": build.get("number"),
|
"number": build.get("number"),
|
||||||
"status": (build.get("result") or "RUNNING").lower(),
|
"status": status,
|
||||||
"finished_at": build.get("timestamp"),
|
"finished_at": build.get("timestamp"),
|
||||||
"duration_seconds": build.get("duration", 0) // 1000,
|
"duration_seconds": build.get("duration", 0) // 1000,
|
||||||
"url": build.get("url"),
|
"url": build.get("url"),
|
||||||
"commits": commits,
|
"commits": commits,
|
||||||
|
"trigger": trigger,
|
||||||
|
"trigger_label": trigger_label,
|
||||||
|
"trigger_url": trigger_url,
|
||||||
|
"trigger_author": trigger_author,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _auth_header() -> Dict[str, str]:
|
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}"
|
token = f"{settings.jenkins_user}:{settings.jenkins_token}"
|
||||||
encoded = base64.b64encode(token.encode()).decode()
|
encoded = base64.b64encode(token.encode()).decode()
|
||||||
return {"Authorization": f"Basic {encoded}"}
|
return {"Authorization": f"Basic {encoded}"}
|
||||||
|
|
||||||
|
|
||||||
def fetch_builds(limit: int = 5) -> List[Dict]:
|
def fetch_builds(limit: int = 5) -> List[Dict]:
|
||||||
|
if not settings.jenkins_job_name:
|
||||||
|
raise ValueError("JENKINS_JOB_NAME not configured")
|
||||||
url = (
|
url = (
|
||||||
f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json"
|
f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json"
|
||||||
"?tree=builds[number,url,result,timestamp,duration,"
|
"?tree=builds[number,url,result,timestamp,duration,building,"
|
||||||
"changesets[items[commitId,msg,author[fullName]]]]"
|
"changeSets[items[commitId,msg,author[fullName]]],"
|
||||||
|
"actions[causes[shortDescription]]]"
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = requests.get(url, headers = _auth_header(), timeout=5)
|
resp = requests.get(url, headers=_auth_header(), timeout=5)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
builds = resp.json().get("builds", [])
|
builds = resp.json().get("builds", [])
|
||||||
return builds[:limit]
|
return _sort_builds(builds)[:limit]
|
||||||
|
|
||||||
|
|
||||||
def build_history() -> Dict:
|
def build_history() -> Dict:
|
||||||
"""Return Jenkins build history data."""
|
"""Return Jenkins build history data."""
|
||||||
builds = fetch_builds()
|
builds = fetch_builds()
|
||||||
return {
|
return {"builds": [normalize_build(b) for b in builds]}
|
||||||
"builds": [normalize_build(b) for b in builds]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def _pick_mains(count: int = ITEMS_PER_SECTION) -> List[str]:
|
|||||||
random.shuffle(mains)
|
random.shuffle(mains)
|
||||||
return mains
|
return mains
|
||||||
|
|
||||||
|
|
||||||
def _pick_garnish() -> List[str]:
|
def _pick_garnish() -> List[str]:
|
||||||
garnish_options = MENU_SOURCE["mains"]["garnish"]
|
garnish_options = MENU_SOURCE["mains"]["garnish"]
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ def _build_alternative() -> Dict:
|
|||||||
"note": alternative.get("note", ""),
|
"note": alternative.get("note", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
|
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ class RuntimeConfig:
|
|||||||
git_commit: str = os.getenv("GIT_COMMIT", "local")
|
git_commit: str = os.getenv("GIT_COMMIT", "local")
|
||||||
build_number: str = os.getenv("BUILD_NUMBER", "-")
|
build_number: str = os.getenv("BUILD_NUMBER", "-")
|
||||||
commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
|
commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
|
||||||
jenkins_base_url: str = os.getenv(
|
jenkins_base_url: str = os.getenv("JENKINS_BASE_URL", "http://localhost:8080").rstrip("/")
|
||||||
"JENKINS_BASE_URL",
|
|
||||||
"http://localhost:8080"
|
|
||||||
).rstrip("/")
|
|
||||||
jenkins_job_name: str = os.getenv("JENKINS_JOB_NAME", "")
|
jenkins_job_name: str = os.getenv("JENKINS_JOB_NAME", "")
|
||||||
jenkins_user: str = os.getenv("JENKINS_USER", "")
|
jenkins_user: str = os.getenv("JENKINS_USER", "")
|
||||||
jenkins_token: str = os.getenv("JENKINS_TOKEN", "")
|
jenkins_token: str = os.getenv("JENKINS_TOKEN", "")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -60,6 +61,15 @@ def test_build_history(monkeypatch):
|
|||||||
"timestamp": 1719992400000,
|
"timestamp": 1719992400000,
|
||||||
"duration": 75000,
|
"duration": 75000,
|
||||||
"url": "http://jenkins.local/job/demo/205",
|
"url": "http://jenkins.local/job/demo/205",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"causes": [
|
||||||
|
{
|
||||||
|
"shortDescription": "Triggered by Merge pull request #37",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"changeSets": [
|
"changeSets": [
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
@@ -78,6 +88,7 @@ def test_build_history(monkeypatch):
|
|||||||
"timestamp": 1719988800000,
|
"timestamp": 1719988800000,
|
||||||
"duration": 1500,
|
"duration": 1500,
|
||||||
"url": "http://jenkins.local/job/demo/204",
|
"url": "http://jenkins.local/job/demo/204",
|
||||||
|
"actions": [],
|
||||||
"changeSets": [],
|
"changeSets": [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -100,9 +111,30 @@ def test_build_history(monkeypatch):
|
|||||||
assert first["commits"] == [
|
assert first["commits"] == [
|
||||||
{"commit": "9ac3f91", "message": "Anade la API de Jenkins", "author": "Dev One"}
|
{"commit": "9ac3f91", "message": "Anade la API de Jenkins", "author": "Dev One"}
|
||||||
]
|
]
|
||||||
|
assert first["trigger"] == "Triggered by Merge pull request #37"
|
||||||
|
assert first["trigger_label"] == "Merge PR #37"
|
||||||
|
assert first["trigger_url"] == ""
|
||||||
|
assert first["trigger_author"] == ""
|
||||||
|
|
||||||
second = builds[1]
|
second = builds[1]
|
||||||
assert second["number"] == 204
|
assert second["number"] == 204
|
||||||
assert second["status"] == "running"
|
assert second["status"] == "running"
|
||||||
assert second["duration_seconds"] == 1
|
assert second["duration_seconds"] == 1
|
||||||
assert second["commits"] == []
|
assert second["commits"] == []
|
||||||
|
assert second["trigger"] == ""
|
||||||
|
assert second["trigger_label"] == ""
|
||||||
|
assert second["trigger_url"] == ""
|
||||||
|
assert second["trigger_author"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
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
1
frontend/.gitignore
vendored
@@ -6,3 +6,4 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.svelte-kit
|
.svelte-kit
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
TESTS.md
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check",
|
"check": "svelte-check",
|
||||||
"test": "vitest"
|
"test": "vitest run --reporter=default --reporter=junit --outputFile=./test-results/junit.xml"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
||||||
|
|||||||
@@ -377,12 +377,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
<span class={`status-dot ${build.status}`}></span>
|
<span class={`status-dot ${build.status}`}></span>
|
||||||
<span class="summary-title">#{build.number}</span>
|
<span class="summary-title">#{build.number}</span>
|
||||||
|
|
||||||
{#if build.commits.length === 0}
|
{#if build.trigger_label || build.trigger}
|
||||||
<span class="muted">Ejecución manual</span>
|
<div class="summary-trigger">
|
||||||
|
{#if build.trigger_url}
|
||||||
|
<a href={build.trigger_url} target="_blank" rel="noreferrer">
|
||||||
|
{build.trigger_label || build.trigger}
|
||||||
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
|
<span>{build.trigger_label || build.trigger}</span>
|
||||||
|
{/if}
|
||||||
|
{#if build.trigger_author}
|
||||||
|
<span class="trigger-author">{build.trigger_author}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if build.commits.length > 0}
|
||||||
<span class="summary-commit">
|
<span class="summary-commit">
|
||||||
{build.commits[0].commit} · {build.commits[0].author}
|
{build.commits[0].commit} · {build.commits[0].author}
|
||||||
</span>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="muted">Ejecución manual</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -394,7 +407,36 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="history-body">
|
<div class="history-body">
|
||||||
|
{#if build.trigger_label || build.trigger}
|
||||||
|
<p class="history-row">
|
||||||
|
<span>Disparo</span>
|
||||||
|
<span>
|
||||||
|
{#if build.trigger_url}
|
||||||
|
<a
|
||||||
|
class="history-link"
|
||||||
|
href={build.trigger_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{build.trigger_label || build.trigger}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{build.trigger_label || build.trigger}
|
||||||
|
{/if}
|
||||||
|
{#if build.trigger_author}
|
||||||
|
<span class="trigger-author">{build.trigger_author}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if build.commits.length > 0}
|
{#if build.commits.length > 0}
|
||||||
|
<p class="history-row">
|
||||||
|
<span>Commit</span>
|
||||||
|
<span class="mono">
|
||||||
|
{build.commits[0].commit} · {build.commits[0].author}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<p class="history-row">
|
<p class="history-row">
|
||||||
<span>Mensaje</span>
|
<span>Mensaje</span>
|
||||||
<span>{build.commits[0].message}</span>
|
<span>{build.commits[0].message}</span>
|
||||||
@@ -408,6 +450,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
{#if build.status === 'failure'}
|
{#if build.status === 'failure'}
|
||||||
<p class="history-message danger">Build fallida</p>
|
<p class="history-message danger">Build fallida</p>
|
||||||
|
{:else if build.status === 'running'}
|
||||||
|
<p class="history-message running">En curso</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="history-message success">Correcto</p>
|
<p class="history-message success">Correcto</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
127
frontend/src/App.test.js
Normal file
127
frontend/src/App.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend/src/services/api.test.js
Normal file
123
frontend/src/services/api.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
frontend/src/setupTests.js
Normal file
19
frontend/src/setupTests.js
Normal 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';
|
||||||
@@ -697,6 +697,25 @@ li {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-trigger a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-trigger a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-meta {
|
.summary-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
@@ -723,6 +742,11 @@ li {
|
|||||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-dot.running {
|
||||||
|
background: #facc15;
|
||||||
|
box-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.history-body {
|
.history-body {
|
||||||
padding: 0.75rem 0.9rem 0.9rem;
|
padding: 0.75rem 0.9rem 0.9rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
@@ -743,6 +767,21 @@ li {
|
|||||||
color: #cbd5f5;
|
color: #cbd5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-link {
|
||||||
|
color: #fbbf24;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.history-message {
|
.history-message {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
background: rgba(248, 113, 113, 0.12);
|
background: rgba(248, 113, 113, 0.12);
|
||||||
@@ -758,6 +797,12 @@ li {
|
|||||||
color: #bbf7d0;
|
color: #bbf7d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-message.running {
|
||||||
|
background: rgba(250, 204, 21, 0.16);
|
||||||
|
border-color: rgba(250, 204, 21, 0.5);
|
||||||
|
color: #fef9c3;
|
||||||
|
}
|
||||||
|
|
||||||
.history-item[open] {
|
.history-item[open] {
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
@@ -770,6 +815,10 @@ li {
|
|||||||
border-color: rgba(248, 113, 113, 0.35);
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-item.running {
|
||||||
|
border-color: rgba(250, 204, 21, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.chip.danger {
|
.chip.danger {
|
||||||
background: rgba(248, 113, 113, 0.2);
|
background: rgba(248, 113, 113, 0.2);
|
||||||
color: #fecdd3;
|
color: #fecdd3;
|
||||||
@@ -820,3 +869,15 @@ li {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
.trigger-author {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.16);
|
||||||
|
color: #cbd5f5;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row .trigger-author {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|||||||
38
frontend/src/utils/text.test.js
Normal file
38
frontend/src/utils/text.test.js
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ['./src/setupTests.js'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user