use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::api::extract::Json;
use crate::api::response::{ApiResponse, Data};
use crate::api::{TAREA_QUE_NO_ESTA, contenido, del_motor};
use crate::db::entities::{AltaDeTarea, Ejecucion, Tarea};
use crate::error::AppError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/jobs", get(listado).post(alta))
.route(
"/api/v1/jobs/{id}",
get(leer).put(actualizar).delete(borrar),
)
.route("/api/v1/jobs/{id}/run", post(disparar))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AltaDeTareaDelCable {
pub target_id: String,
pub repository_id: String,
pub cron_expression: String,
pub keep_daily: i64,
pub keep_weekly: i64,
pub keep_monthly: i64,
pub enabled: bool,
pub name: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TareaDelCable {
pub id: String,
pub target_id: String,
pub repository_id: String,
pub cron_expression: String,
pub keep_daily: i64,
pub keep_weekly: i64,
pub keep_monthly: i64,
pub enabled: bool,
pub created_at: String,
pub name: Option<String>,
}
impl TareaDelCable {
fn desde(fila: &Tarea) -> Result<Self, AppError> {
Ok(Self {
id: fila.id.hyphenated().to_string(),
target_id: fila.target_id.hyphenated().to_string(),
repository_id: fila.repository_id.hyphenated().to_string(),
cron_expression: fila.cron_expression.clone(),
keep_daily: fila.keep_daily,
keep_weekly: fila.keep_weekly,
keep_monthly: fila.keep_monthly,
enabled: fila.enabled,
created_at: fila.created_at.format(&Rfc3339).map_err(|error| {
AppError::Other(format!("la fecha de la fila no se pudo formatear: {error}"))
})?,
name: fila.name.clone(),
})
}
}
#[derive(Debug, Serialize)]
pub struct EjecucionEncoladaDelCable {
pub id: String,
pub job_id: String,
pub status: String,
}
impl EjecucionEncoladaDelCable {
fn desde(ejecucion: &Ejecucion) -> Self {
Self {
id: ejecucion.id.hyphenated().to_string(),
job_id: ejecucion.job_id.hyphenated().to_string(),
status: ejecucion.status.texto().to_owned(),
}
}
}
fn identificador(id: &str) -> Result<Uuid, AppError> {
Uuid::parse_str(id).map_err(|_| {
AppError::InvalidInput("el campo `id` de la ruta no tiene la forma de un UUID".to_owned())
})
}
fn tarea_ausente() -> AppError {
AppError::NotFound(TAREA_QUE_NO_ESTA.to_owned())
}
fn referencia(campo: &'static str, valor: &str) -> Result<Uuid, AppError> {
Uuid::parse_str(valor).map_err(|_| {
AppError::InvalidInput(format!("el campo `{campo}` no tiene la forma de un UUID"))
})
}
async fn target_resuelto(estado: &AppState, valor: &str) -> Result<Uuid, AppError> {
let uuid = referencia("target_id", valor)?;
if estado.db.targets.leer(&uuid).await?.is_none() {
return Err(AppError::InvalidInput(
"el campo `target_id` no apunta a ningún target existente".to_owned(),
));
}
Ok(uuid)
}
async fn repositorio_resuelto(estado: &AppState, valor: &str) -> Result<Uuid, AppError> {
let uuid = referencia("repository_id", valor)?;
if estado.db.repositorios.leer(&uuid).await?.is_none() {
return Err(AppError::InvalidInput(
"el campo `repository_id` no apunta a ningún repositorio existente".to_owned(),
));
}
Ok(uuid)
}
fn retencion_que_conserva(cuerpo: &AltaDeTareaDelCable) -> Result<(), AppError> {
if cuerpo.keep_daily == 0 && cuerpo.keep_weekly == 0 && cuerpo.keep_monthly == 0 {
return Err(AppError::InvalidInput(
"las tres reglas de retención (`keep_daily`, `keep_weekly` y `keep_monthly`) no pueden \
ser cero a la vez: sin ninguna regla que conserve, la copia siguiente borraría todo el \
historial del repositorio"
.to_owned(),
));
}
Ok(())
}
fn cron_que_el_reloj_sepa_leer(cuerpo: &AltaDeTareaDelCable) -> Result<(), AppError> {
crate::scheduler::validar(&cuerpo.cron_expression).map_err(|error| {
AppError::InvalidInput(format!(
"el campo `cron_expression` no es una expresión cron válida: {error}"
))
})?;
Ok(())
}
const NOMBRE_MAXIMO: usize = 120;
fn nombre_del_cuerpo(cuerpo: &AltaDeTareaDelCable) -> Result<Option<String>, AppError> {
let Some(nombre) = cuerpo.name.as_deref() else {
return Ok(None);
};
let recortado = nombre.trim();
if recortado.chars().count() > NOMBRE_MAXIMO {
return Err(AppError::InvalidInput(format!(
"el campo `name` no puede superar los {NOMBRE_MAXIMO} caracteres"
)));
}
if recortado.is_empty() {
return Ok(None);
}
Ok(Some(recortado.to_owned()))
}
async fn alta_desde(
estado: &AppState,
cuerpo: &AltaDeTareaDelCable,
) -> Result<AltaDeTarea, AppError> {
retencion_que_conserva(cuerpo)?;
cron_que_el_reloj_sepa_leer(cuerpo)?;
let name = nombre_del_cuerpo(cuerpo)?;
let target_id = target_resuelto(estado, &cuerpo.target_id).await?;
let repository_id = repositorio_resuelto(estado, &cuerpo.repository_id).await?;
Ok(AltaDeTarea {
target_id,
repository_id,
cron_expression: cuerpo.cron_expression.clone(),
keep_daily: cuerpo.keep_daily,
keep_weekly: cuerpo.keep_weekly,
keep_monthly: cuerpo.keep_monthly,
enabled: cuerpo.enabled,
name,
})
}
pub async fn listado(State(state): State<AppState>) -> Result<ApiResponse, AppError> {
let filas = state.db.tareas.listar().await?;
let vistas: Vec<TareaDelCable> = filas
.iter()
.map(TareaDelCable::desde)
.collect::<Result<_, _>>()?;
Ok(ApiResponse::new(
StatusCode::OK,
"tareas listadas",
contenido(&vistas)?,
))
}
pub async fn alta(
State(state): State<AppState>,
Json(cuerpo): Json<AltaDeTareaDelCable>,
) -> Result<ApiResponse, AppError> {
let alta = alta_desde(&state, &cuerpo).await?;
let fila = state.db.tareas.alta(alta).await?;
let vista = TareaDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::CREATED,
"tarea creada",
contenido(&vista)?,
))
}
pub async fn leer(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let Some(fila) = state.db.tareas.leer(&uuid).await? else {
return Err(tarea_ausente());
};
let vista = TareaDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"tarea encontrada",
contenido(&vista)?,
))
}
pub async fn actualizar(
State(state): State<AppState>,
Path(id): Path<String>,
Json(cuerpo): Json<AltaDeTareaDelCable>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let Some(actual) = state.db.tareas.leer(&uuid).await? else {
return Err(tarea_ausente());
};
let alta = alta_desde(&state, &cuerpo).await?;
let fila = Tarea {
id: actual.id,
target_id: alta.target_id,
repository_id: alta.repository_id,
cron_expression: alta.cron_expression,
keep_daily: alta.keep_daily,
keep_weekly: alta.keep_weekly,
keep_monthly: alta.keep_monthly,
enabled: alta.enabled,
created_at: actual.created_at,
name: alta.name,
};
if !state.db.tareas.actualizar(&fila).await? {
return Err(tarea_ausente());
}
let vista = TareaDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"tarea actualizada",
contenido(&vista)?,
))
}
pub async fn borrar(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
if !state.db.tareas.borrar(&uuid).await? {
return Err(tarea_ausente());
}
Ok(ApiResponse::new(
StatusCode::OK,
"tarea borrada",
Data::None,
))
}
pub async fn disparar(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let ejecucion = state.motor.encolar(uuid).await.map_err(del_motor)?;
let vista = EjecucionEncoladaDelCable::desde(&ejecucion);
Ok(ApiResponse::new(
StatusCode::ACCEPTED,
"ejecución encolada",
contenido(&vista)?,
))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::sync::Arc;
use axum::Router;
use axum::http::StatusCode;
use serde_json::Value;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::api::api_router;
use crate::db::entities::{
AltaDeEjecucion, AltaDeRepositorio, AltaDeTarea, AltaDeTarget, Estado, TipoDeTarget,
};
use crate::secret::SecretString;
use crate::state::{AppState, EstadoDeBase};
use crate::test_support::{
CLAVE_SECRETA_DE_PRUEBA, DirectorioDePrueba, PASSPHRASE_DE_PRUEBA, base_con_clave,
body_json, body_text, capturar_log, config_con_dsn, delete, destino_local_de_prueba,
directorio_de_prueba, estado_de_prueba, estado_desarrollo, get, post, post_json, put_json,
};
const CREDENCIAL_EN_UN_CAMPO_NUMERICO: &str = "AKIAIOSFODNN7EXAMPLE-credencial-numerica-0001";
async fn target_de_prueba(router: &Router, nombre: &str) -> String {
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&serde_json::json!({
"name": nombre,
"target_type": "mariadb",
"container_name": "contenedor-de-pruebas",
"db_user": "usuario-de-pruebas",
"db_password": "contrasena-de-pruebas-000000000001",
"db_name": "base-de-pruebas",
}),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el target de la siembra tiene que darse de alta antes de hablar de tareas, y contestó \
{status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el alta del target debe devolver su identificador")
.to_owned()
}
async fn repositorio_de_prueba(router: &Router, nombre: &str) -> String {
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&serde_json::json!({
"name": nombre,
"endpoint": destino_local_de_prueba(nombre),
"bucket": format!("cubo-{nombre}"),
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "AKIAIOSFODNN7EXAMPLE-secreto-de-pruebas",
"passphrase": "passphrase-distinguible-00000000000000000000000001",
}),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el repositorio de la siembra tiene que darse de alta antes de hablar de tareas, y \
contestó {status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el alta del repositorio debe devolver su identificador")
.to_owned()
}
fn cuerpo_de_tarea(target_id: &str, repository_id: &str) -> serde_json::Value {
serde_json::json!({
"target_id": target_id,
"repository_id": repository_id,
"cron_expression": "0 3 * * *",
"keep_daily": 7,
"keep_weekly": 4,
"keep_monthly": 6,
"enabled": true,
})
}
fn cuerpo_de_tarea_con_nombre(
target_id: &str,
repository_id: &str,
nombre: &str,
) -> serde_json::Value {
let mut cuerpo = cuerpo_de_tarea(target_id, repository_id);
cuerpo["name"] = serde_json::json!(nombre);
cuerpo
}
#[tokio::test]
async fn las_cinco_rutas_del_recurso_contestan_su_codigo() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-de-1-6").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-de-1-6").await;
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado, y contestó {status}"
);
let cuerpo = body_json(respuesta).await;
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let respuesta = get(router.clone(), "/api/v1/jobs").await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"el listado tiene que contestar 200 con un array en `data`, y contestó {status}"
);
let _ = body_json(respuesta).await;
let respuesta = get(router.clone(), &format!("/api/v1/jobs/{id}")).await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"la lectura de una fila recién creada tiene que contestar 200, y contestó {status}"
);
let _ = body_json(respuesta).await;
let mut actualizacion = cuerpo_de_tarea(&target, &repositorio);
actualizacion["keep_daily"] = serde_json::json!(3);
let respuesta = put_json(
router.clone(),
&format!("/api/v1/jobs/{id}"),
&actualizacion,
)
.await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"la actualización tiene que contestar 200 con el recurso, y contestó {status}"
);
let _ = body_json(respuesta).await;
let respuesta = delete(router, &format!("/api/v1/jobs/{id}")).await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"el borrado tiene que contestar 200 con `data` nulo, y contestó {status}"
);
let _ = body_json(respuesta).await;
}
#[tokio::test]
async fn el_alta_devuelve_los_campos_con_los_nombres_del_esquema() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-de-1-13j").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-de-1-13j").await;
let respuesta = post_json(
router,
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado, y contestó {status} con \
{cuerpo}"
);
let claves: BTreeSet<&str> = cuerpo["data"]
.as_object()
.expect("el `data` del alta tiene que ser un objeto con la tarea del cable")
.keys()
.map(String::as_str)
.collect();
let esperadas: BTreeSet<&str> = BTreeSet::from([
"created_at",
"cron_expression",
"enabled",
"id",
"keep_daily",
"keep_monthly",
"keep_weekly",
"name",
"repository_id",
"target_id",
]);
assert_eq!(
claves, esperadas,
"la vista del cable tiene que traer exactamente los campos del §3, con sus nombres: \
{cuerpo}"
);
}
#[tokio::test]
async fn una_tarea_con_un_target_inexistente_contesta_400_nombrando_el_campo() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let cuerpo_de_la_peticion =
cuerpo_de_tarea(&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string());
let respuesta = post_json(api_router(estado), "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una referencia que no resuelve es un formulario mal rellenado, no un servidor roto: \
contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("target_id"),
"el rechazo tiene que nombrar el campo que no resuelve —y la clave foránea no puede \
decirlo—, y el mensaje fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn una_tarea_con_un_repositorio_inexistente_contesta_400_nombrando_el_campo() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-de-1-29b").await;
let cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &Uuid::new_v4().to_string());
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"con el target resuelto, lo que no resuelve es el repositorio: contestó {status} con \
{cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("repository_id"),
"el rechazo tiene que nombrar la referencia que falla —y no la otra—, y el mensaje fue \
{mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn un_target_id_que_no_es_un_uuid_contesta_400_nombrando_el_campo() {
let router = api_router(estado_desarrollo().await);
let cuerpo_de_la_peticion = cuerpo_de_tarea("no-es-un-uuid", &Uuid::new_v4().to_string());
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una cadena que no es un identificador es una petición mal formada: contestó {status} \
con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("target_id"),
"el rechazo tiene que nombrar el campo que no resuelve, y el mensaje fue {mensaje:?}"
);
assert!(
!mensaje.contains("no-es-un-uuid"),
"el rechazo nombra el campo, nunca el valor que traía: lo puso quien lo mandó, y \
repetirlo lo llevaría al cuerpo de la respuesta y de ahí a cualquier registro \
intermedio; el mensaje fue {mensaje:?}"
);
}
#[tokio::test]
async fn un_campo_de_mas_en_el_cuerpo_se_rechaza() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-de-1-30j").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-de-1-30j").await;
let mut cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &repositorio);
cuerpo_de_la_peticion["keep_dailly"] = serde_json::json!(7);
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un campo que el §3 no conoce es una errata del formulario, y aceptarlo en silencio \
guardaría otra cosa que la pedida: contestó {status} con {cuerpo}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn el_rechazo_de_un_campo_con_el_tipo_equivocado_no_publica_la_credencial() {
let (buffer, _guard) = capturar_log();
let router = api_router(estado_desarrollo().await);
let mut en_el_valor = cuerpo_de_tarea("target-de-1-24j", "repositorio-de-1-24j");
en_el_valor["keep_daily"] = Value::String(CREDENCIAL_EN_UN_CAMPO_NUMERICO.to_owned());
let mut en_el_nombre = cuerpo_de_tarea("target-de-1-24j", "repositorio-de-1-24j");
en_el_nombre[CREDENCIAL_EN_UN_CAMPO_NUMERICO] = serde_json::json!(1);
let vectores = [
("la credencial entera en un campo numérico", en_el_valor),
("la credencial como nombre de un campo de más", en_el_nombre),
];
for (motivo, cuerpo_de_la_peticion) in vectores {
let respuesta = post_json(router.clone(), "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let texto = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"el rechazo tiene que salir por la envoltura ({motivo}); contestó {status} con \
{texto}"
);
assert!(
!texto.contains(CREDENCIAL_EN_UN_CAMPO_NUMERICO),
"el rechazo publicó la credencial al cliente ({motivo}): {texto}"
);
}
let registrado = buffer.texto();
assert!(
!registrado.contains(CREDENCIAL_EN_UN_CAMPO_NUMERICO),
"el rechazo escribió la credencial en el registro ({registrado})"
);
assert!(
registrado.contains("no se pudo interpretar"),
"tiene que quedar registrado que una petición no se pudo interpretar —sin su \
texto—, y el registro capturado fue: {registrado}"
);
}
#[tokio::test]
async fn un_identificador_de_tarea_mal_formado_contesta_400() {
let router = api_router(estado_desarrollo().await);
let respuesta = get(router, "/api/v1/jobs/no-es-un-uuid").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un identificador que no es un UUID no puede ser un recurso ausente ni un fallo del \
servidor: contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["status"], 400,
"el rechazo tiene que llegar en la envoltura del resto de la API, con el código \
dentro: {cuerpo}"
);
assert!(
cuerpo["message"].is_string(),
"la envoltura de error tiene que traer un `message` de texto: {cuerpo}"
);
}
struct AparatoDelDisparo {
_base: DirectorioDePrueba,
_destino: DirectorioDePrueba,
pool: SqlitePool,
estado: AppState,
tarea: Uuid,
}
impl AparatoDelDisparo {
async fn nuevo() -> Self {
let (base, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&base.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let destino = directorio_de_prueba();
let target = estado
.db
.targets
.alta(AltaDeTarget {
name: "target-del-disparo".to_owned(),
target_type: TipoDeTarget::Mariadb,
container_name: Some("kesto-prueba-que-no-existe".to_owned()),
db_user: Some("usuario-de-pruebas".to_owned()),
db_password: Some(SecretString::new(
"contrasena-de-pruebas-000000000001".to_owned(),
)),
db_name: Some("base-de-pruebas".to_owned()),
host_path: None,
db_path: None,
})
.await
.expect("el target de la siembra se da de alta");
let repositorio = estado
.db
.repositorios
.alta(AltaDeRepositorio {
name: "repositorio-del-disparo".to_owned(),
endpoint: destino.ruta().to_string_lossy().into_owned(),
bucket: "cubo-del-disparo".to_owned(),
region: "region-de-prueba".to_owned(),
access_key_id: "clave-de-acceso-de-prueba".to_owned(),
secret_access_key: SecretString::new(CLAVE_SECRETA_DE_PRUEBA.to_owned()),
passphrase: SecretString::new(PASSPHRASE_DE_PRUEBA.to_owned()),
})
.await
.expect("el repositorio de la siembra se da de alta");
let tarea = estado
.db
.tareas
.alta(AltaDeTarea {
target_id: target.id,
repository_id: repositorio.id,
cron_expression: "0 3 * * *".to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
name: None,
})
.await
.expect("la tarea de la siembra se da de alta");
Self {
_base: base,
_destino: destino,
pool,
estado,
tarea: tarea.id,
}
}
fn router(&self) -> Router {
api_router(self.estado.clone())
}
async fn ejecuciones(&self) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM job_executions")
.fetch_one(&self.pool)
.await
.expect("la tabla de ejecuciones tiene que poder contarse")
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_disparo_contesta_202_con_la_ejecucion_encolada() {
let aparato = AparatoDelDisparo::nuevo().await;
let respuesta = post(
aparato.router(),
&format!("/api/v1/jobs/{}/run", aparato.tarea),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::ACCEPTED,
"el disparo tiene que contestar 202 —aceptada, no creada— y contestó {status} con \
{cuerpo}"
);
assert_eq!(
cuerpo["data"]["job_id"],
aparato.tarea.hyphenated().to_string(),
"el `data` del disparo tiene que traer el `job_id` de la ejecución: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["status"], "pending",
"la ejecución del disparo nace en `pending`: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el disparo tiene que devolver el identificador de la ejecución")
.to_owned();
assert_eq!(
aparato.ejecuciones().await,
1,
"el disparo tiene que dejar exactamente una ejecución escrita"
);
let estado_escrito: String =
sqlx::query_scalar("SELECT status FROM job_executions WHERE id = ?1")
.bind(&id)
.fetch_one(&aparato.pool)
.await
.expect("la fila de la ejecución tiene que estar en la base");
assert!(
["pending", "running"].contains(&estado_escrito.as_str()),
"la fila del disparo tiene que estar en curso, y está en `{estado_escrito}`"
);
let estado_final: String = esperar_el_final(&aparato.pool, &id).await;
assert_ne!(
estado_final, "running",
"la ejecución del disparo se quedó en `running`: el panel la pintaría como en marcha \
para siempre"
);
}
async fn esperar_el_final(pool: &SqlitePool, id: &str) -> String {
let limite = std::time::Instant::now() + std::time::Duration::from_secs(90);
loop {
let estado: String =
sqlx::query_scalar("SELECT status FROM job_executions WHERE id = ?1")
.bind(id)
.fetch_one(pool)
.await
.expect("la fila de la ejecución tiene que estar en la base");
if !["pending", "running"].contains(&estado.as_str()) {
return estado;
}
assert!(
std::time::Instant::now() < limite,
"la ejecución no ha terminado en el plazo de la prueba: su estado sigue siendo \
`{estado}`"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_disparo_sobre_una_tarea_que_ya_corre_contesta_409() {
let aparato = AparatoDelDisparo::nuevo().await;
aparato
.estado
.db
.ejecuciones
.alta(AltaDeEjecucion {
job_id: aparato.tarea,
status: Estado::Running,
snapshot_id: None,
bytes_processed: 0,
bytes_uploaded: 0,
error_message: None,
started_at: time::OffsetDateTime::now_utc(),
finished_at: None,
})
.await
.expect("la ejecución en curso de la siembra se escribe");
let respuesta = post(
aparato.router(),
&format!("/api/v1/jobs/{}/run", aparato.tarea),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CONFLICT,
"una tarea con una ejecución en curso es un conflicto con el estado actual del \
recurso, y contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["status"], 409,
"la envoltura tiene que repetir el código: {cuerpo}"
);
assert_eq!(
aparato.ejecuciones().await,
1,
"el 409 creó una segunda ejecución para la misma tarea"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_disparo_sobre_una_tarea_que_no_existe_contesta_404() {
let aparato = AparatoDelDisparo::nuevo().await;
let inexistente = Uuid::new_v4();
let respuesta = post(aparato.router(), &format!("/api/v1/jobs/{inexistente}/run")).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"no hay nada que encolar para una tarea que no está: contestó {status} con {cuerpo}"
);
assert_eq!(
aparato.ejecuciones().await,
0,
"un disparo sobre una tarea que no existe dejó una fila escrita"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_identificador_de_disparo_mal_formado_contesta_400() {
let aparato = AparatoDelDisparo::nuevo().await;
let respuesta = post(aparato.router(), "/api/v1/jobs/no-es-un-uuid/run").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un identificador que no es un UUID no puede ser un recurso ausente: contestó {status} \
con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto");
assert!(
mensaje.contains("id"),
"el rechazo tiene que nombrar el campo que no resuelve, y el mensaje fue {mensaje:?}"
);
assert_eq!(
aparato.ejecuciones().await,
0,
"una petición rechazada no puede dejar ninguna ejecución escrita"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn una_tarea_desactivada_tambien_se_dispara_a_mano() {
let aparato = AparatoDelDisparo::nuevo().await;
sqlx::query("UPDATE backup_jobs SET enabled = 0 WHERE id = ?")
.bind(aparato.tarea.hyphenated().to_string())
.execute(&aparato.pool)
.await
.expect("la tarea del aparato tiene que poder desactivarse");
let respuesta = post(
aparato.router(),
&format!("/api/v1/jobs/{}/run", aparato.tarea),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::ACCEPTED,
"una tarea desactivada se dispara a mano —`enabled` gobierna al planificador, no al \
botón— y contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["data"]["job_id"],
aparato.tarea.hyphenated().to_string(),
"la ejecución que se encola tiene que ser de la tarea que se disparó: {cuerpo}"
);
}
#[tokio::test]
async fn una_tarea_con_las_tres_reglas_a_cero_se_rechaza_al_darla_de_alta() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-de-la-retencion").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-de-la-retencion").await;
let mut cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &repositorio);
cuerpo_de_la_peticion["keep_daily"] = serde_json::json!(0);
cuerpo_de_la_peticion["keep_weekly"] = serde_json::json!(0);
cuerpo_de_la_peticion["keep_monthly"] = serde_json::json!(0);
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una retención con las tres reglas a cero borra todo el historial en la copia \
siguiente, así que no se acepta: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto");
for columna in ["keep_daily", "keep_weekly", "keep_monthly"] {
assert!(
mensaje.contains(columna),
"el rechazo tiene que nombrar las tres columnas —{columna} no está—, y el mensaje \
fue {mensaje:?}"
);
}
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect("la tabla de tareas tiene que poder contarse");
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn una_tarea_con_las_tres_reglas_a_cero_se_rechaza_al_editarla() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-de-la-retencion-editada").await;
let repositorio =
repositorio_de_prueba(&router, "repositorio-de-la-retencion-editada").await;
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"la tarea tiene que darse de alta antes de poder editarla"
);
let cuerpo = body_json(respuesta).await;
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let mut edicion = cuerpo_de_tarea(&target, &repositorio);
edicion["keep_daily"] = serde_json::json!(0);
edicion["keep_weekly"] = serde_json::json!(0);
edicion["keep_monthly"] = serde_json::json!(0);
let respuesta = put_json(router, &format!("/api/v1/jobs/{id}"), &edicion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"la edición que deja las tres reglas a cero tiene que rechazarse: contestó {status} \
con {cuerpo}"
);
let guardadas: (i64, i64, i64) = sqlx::query_as(
"SELECT keep_daily, keep_weekly, keep_monthly FROM backup_jobs WHERE id = ?1",
)
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila de la tarea tiene que seguir en la base");
assert_eq!(
guardadas,
(7, 4, 6),
"el rechazo de la edición dejó la fila cambiada: {guardadas:?}"
);
}
#[tokio::test]
async fn un_cero_suelto_se_acepta() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-del-cero-suelto").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-cero-suelto").await;
let mut cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &repositorio);
cuerpo_de_la_peticion["keep_daily"] = serde_json::json!(0);
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un cero acompañado de otra regla informada es una decisión, no un error: contestó \
{status} con {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let guardadas: (i64, i64, i64) = sqlx::query_as(
"SELECT keep_daily, keep_weekly, keep_monthly FROM backup_jobs WHERE id = ?1",
)
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila de la tarea aceptada tiene que estar en la base");
assert_eq!(
guardadas,
(0, 4, 6),
"la fila no guardó la retención que informó la petición: {guardadas:?}"
);
}
const EXPRESION_QUE_NO_PARSEA: &str = "cada lunes a las tres";
#[tokio::test]
async fn el_alta_con_una_expresion_de_cinco_campos_se_escribe() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-del-cron-valido").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-cron-valido").await;
let respuesta = post_json(
router,
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"una expresión de cinco campos es válida y tiene que darse de alta: contestó {status} \
con {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let guardada: String =
sqlx::query_scalar("SELECT cron_expression FROM backup_jobs WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila de la tarea aceptada tiene que estar en la base");
assert_eq!(
guardada, "0 3 * * *",
"la fila no guardó la expresión que informó la petición: {guardada:?}"
);
}
#[tokio::test]
async fn el_alta_con_una_expresion_invalida_contesta_400_y_no_deja_fila() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-del-cron-invalido").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-cron-invalido").await;
let mut cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &repositorio);
cuerpo_de_la_peticion["cron_expression"] = serde_json::json!(EXPRESION_QUE_NO_PARSEA);
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una expresión que el analizador no entiende es un formulario mal rellenado, no un \
servidor roto: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
!mensaje.trim().is_empty(),
"el motivo del rechazo tiene que decir por qué la expresión no vale, y llegó vacío"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect("la tabla de tareas tiene que poder contarse");
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn la_edicion_con_una_expresion_invalida_deja_la_fila_como_estaba() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-del-cron-editado").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-cron-editado").await;
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"la tarea tiene que darse de alta antes de poder editarla"
);
let cuerpo = body_json(respuesta).await;
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let mut edicion = cuerpo_de_tarea(&target, &repositorio);
edicion["cron_expression"] = serde_json::json!(EXPRESION_QUE_NO_PARSEA);
let respuesta = put_json(router, &format!("/api/v1/jobs/{id}"), &edicion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"la edición que deja una expresión que el analizador no entiende tiene que rechazarse: \
contestó {status} con {cuerpo}"
);
let guardada: String =
sqlx::query_scalar("SELECT cron_expression FROM backup_jobs WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila de la tarea tiene que seguir en la base");
assert_eq!(
guardada, "0 3 * * *",
"el rechazo de la edición dejó la fila con la expresión nueva: {guardada:?}"
);
}
#[tokio::test]
async fn el_motivo_del_rechazo_nombra_el_campo_cron_expression() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-del-campo").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-del-campo").await;
let mut cuerpo_de_la_peticion = cuerpo_de_tarea(&target, &repositorio);
cuerpo_de_la_peticion["cron_expression"] = serde_json::json!(EXPRESION_QUE_NO_PARSEA);
let respuesta = post_json(router, "/api/v1/jobs", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una expresión que no parsea tiene que rechazarse antes de comprobar el nombre del \
campo: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("cron_expression"),
"el motivo tiene que nombrar el campo del formulario que hay que corregir, y el \
mensaje fue {mensaje:?}"
);
}
#[tokio::test]
async fn el_alta_guarda_y_publica_el_nombre_recortado() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-recortado").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-recortado").await;
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, " copia nocturna "),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un nombre con espacios alrededor tiene que aceptarse y recortarse, no rechazarse: \
contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["data"]["name"], "copia nocturna",
"el alta tiene que publicar el nombre **recortado**: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let lectura = body_json(get(router.clone(), &format!("/api/v1/jobs/{id}")).await).await;
assert_eq!(
lectura["data"]["name"], "copia nocturna",
"la fila guardada tiene que traer el nombre recortado, no el que llegó: {lectura}"
);
let listado = body_json(get(router, "/api/v1/jobs").await).await;
assert_eq!(
listado["data"][0]["name"], "copia nocturna",
"el listado tiene que publicar el mismo nombre que la lectura: {listado}"
);
}
#[tokio::test]
async fn la_edicion_acepta_recorta_y_limpia_el_nombre() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-de-la-edicion-del-nombre").await;
let repositorio =
repositorio_de_prueba(&router, "repositorio-de-la-edicion-del-nombre").await;
let alta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
let status = alta.status();
let alta = body_json(alta).await;
assert_eq!(
status,
StatusCode::CREATED,
"la tarea tiene que darse de alta antes de editarla: contestó {status} con {alta}"
);
let id = alta["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let mut edicion = cuerpo_de_tarea_con_nombre(&target, &repositorio, " copia nocturna ");
edicion["keep_daily"] = serde_json::json!(3);
let respuesta = put_json(router.clone(), &format!("/api/v1/jobs/{id}"), &edicion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"la edición con un nombre —recortable— tiene que aceptarse: contestó {status} con \
{cuerpo}"
);
assert_eq!(
cuerpo["data"]["name"], "copia nocturna",
"la edición tiene que publicar el nombre recortado: {cuerpo}"
);
let lectura = body_json(get(router.clone(), &format!("/api/v1/jobs/{id}")).await).await;
assert_eq!(
lectura["data"]["name"], "copia nocturna",
"la fila editada tiene que guardar el nombre recortado, no el que llegó: {lectura}"
);
let mut blanco = cuerpo_de_tarea_con_nombre(&target, &repositorio, " ");
blanco["keep_daily"] = serde_json::json!(3);
let respuesta = put_json(router.clone(), &format!("/api/v1/jobs/{id}"), &blanco).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"limpiar el nombre tiene que ser una edición válida: contestó {status} con {cuerpo}"
);
assert!(
cuerpo["data"]["name"].is_null(),
"un nombre en blanco en la edición tiene que dejar la tarea sin nombre: {cuerpo}"
);
let lectura = body_json(get(router, &format!("/api/v1/jobs/{id}")).await).await;
assert!(
lectura["data"]["name"].is_null(),
"la fila editada no puede quedarse con una cadena vacía donde el contrato dice `null`: \
{lectura}"
);
}
#[tokio::test]
async fn un_nombre_en_blanco_se_guarda_como_null() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-en-blanco").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-en-blanco").await;
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, " "),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un nombre de solo espacios es una tarea sin nombre y tiene que aceptarse: contestó \
{status} con {cuerpo}"
);
assert!(
cuerpo["data"]["name"].is_null(),
"un nombre en blanco tiene que publicarse como `null`: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let lectura = body_json(get(router, &format!("/api/v1/jobs/{id}")).await).await;
assert!(
lectura["data"]["name"].is_null(),
"la fila guardada no puede llevar una cadena vacía donde el contrato dice `null`: \
{lectura}"
);
}
#[tokio::test]
async fn un_nombre_de_mas_de_120_caracteres_contesta_400_nombrando_el_campo() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
let target = target_de_prueba(&router, "target-del-nombre-largo").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-largo").await;
let largo = "n".repeat(121);
let respuesta = post_json(
router,
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, &largo),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un nombre de 121 caracteres supera el tope de 120 y tiene que rechazarse: contestó \
{status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto");
assert!(
mensaje.contains("name"),
"el rechazo tiene que nombrar el campo `name` para que se pueda corregir desde el \
formulario, y el mensaje fue {mensaje:?}"
);
assert!(
!mensaje.contains(&largo),
"el rechazo nombra el campo, nunca el valor que traía, y el mensaje fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn el_listado_publica_el_nombre_anulable_de_cada_tarea() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-listado-con-nombre").await;
let repositorio =
repositorio_de_prueba(&router, "repositorio-del-listado-con-nombre").await;
let con_nombre = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, "copia nocturna"),
)
.await;
let status = con_nombre.status();
let con_nombre = body_json(con_nombre).await;
assert_eq!(
status,
StatusCode::CREATED,
"la tarea con nombre tiene que darse de alta: contestó {status} con {con_nombre}"
);
let id_con_nombre = con_nombre["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let sin_nombre = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea(&target, &repositorio),
)
.await;
let status = sin_nombre.status();
let sin_nombre = body_json(sin_nombre).await;
assert_eq!(
status,
StatusCode::CREATED,
"la tarea sin nombre —sin la clave o con ella en blanco— tiene que darse de alta igual: \
contestó {status} con {sin_nombre}"
);
let id_sin_nombre = sin_nombre["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let respuesta = get(router, "/api/v1/jobs").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado tiene que contestar 200 con un array en `data`: contestó {status} con \
{cuerpo}"
);
let filas = cuerpo["data"]
.as_array()
.expect("el listado tiene que llegar como un array");
assert_eq!(filas.len(), 2, "tienen que salir las dos tareas: {cuerpo}");
let con = filas
.iter()
.find(|fila| fila["id"].as_str() == Some(id_con_nombre.as_str()))
.unwrap_or_else(|| panic!("la tarea con nombre no está en el listado: {cuerpo}"));
assert_eq!(
con["name"], "copia nocturna",
"la fila de la tarea con nombre tiene que publicar su `name`: {cuerpo}"
);
let sin = filas
.iter()
.find(|fila| fila["id"].as_str() == Some(id_sin_nombre.as_str()))
.unwrap_or_else(|| panic!("la tarea sin nombre no está en el listado: {cuerpo}"));
assert!(
sin.get("name").is_some(),
"la fila de una tarea sin nombre tiene que traer la **clave** `name`, aunque sea a \
`null`: {cuerpo}"
);
assert!(
sin["name"].is_null(),
"una tarea sin nombre publica `name` a `null`, no una cadena vacía: {cuerpo}"
);
}
#[tokio::test]
async fn el_tope_de_120_caracteres_se_acepta_en_el_alta() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-justo").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-justo").await;
let justo = "n".repeat(120);
assert_eq!(
justo.chars().count(),
120,
"la cadena de la prueba tiene que tener exactamente 120 caracteres"
);
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, &justo),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un nombre de exactamente 120 caracteres está en el tope y tiene que aceptarse: contestó \
{status} con {cuerpo}"
);
let devuelto = cuerpo["data"]["name"]
.as_str()
.expect("el alta tiene que devolver el nombre como texto");
assert_eq!(
devuelto.chars().count(),
120,
"el alta tiene que devolver el nombre con sus 120 caracteres, sin truncarlo: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let lectura = body_json(get(router, &format!("/api/v1/jobs/{id}")).await).await;
assert_eq!(
lectura["data"]["name"].as_str(),
Some(justo.as_str()),
"la fila guardada tiene que conservar el nombre entero, con sus 120 caracteres: {lectura}"
);
}
#[tokio::test]
async fn el_tope_del_nombre_se_mide_en_caracteres_y_no_en_bytes() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-acentuado").await;
let repositorio = repositorio_de_prueba(&router, "repositorio-del-nombre-acentuado").await;
let acentuado = "á".repeat(120);
assert_eq!(
acentuado.chars().count(),
120,
"la cadena de la prueba tiene que tener 120 caracteres"
);
assert_eq!(
acentuado.len(),
240,
"y 240 bytes en UTF-8, que es lo que un tope medido con `len()` habría contado"
);
let respuesta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, &acentuado),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un nombre de 120 caracteres acentuados —240 bytes— cabe en el tope en caracteres y \
tiene que aceptarse: contestó {status} con {cuerpo}"
);
let devuelto = cuerpo["data"]["name"]
.as_str()
.expect("el alta tiene que devolver el nombre como texto");
assert_eq!(
devuelto.chars().count(),
120,
"el alta tiene que devolver los 120 caracteres, no 240: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let lectura = body_json(get(router, &format!("/api/v1/jobs/{id}")).await).await;
assert_eq!(
lectura["data"]["name"].as_str(),
Some(acentuado.as_str()),
"la fila guardada tiene que conservar los 120 caracteres acentuados: {lectura}"
);
}
#[tokio::test]
async fn un_nombre_de_121_caracteres_contesta_400_en_la_edicion() {
let router = api_router(estado_desarrollo().await);
let target = target_de_prueba(&router, "target-del-nombre-largo-en-la-edicion").await;
let repositorio =
repositorio_de_prueba(&router, "repositorio-del-nombre-largo-en-la-edicion").await;
let alta = post_json(
router.clone(),
"/api/v1/jobs",
&cuerpo_de_tarea_con_nombre(&target, &repositorio, "copia nocturna"),
)
.await;
let status = alta.status();
let alta = body_json(alta).await;
assert_eq!(
status,
StatusCode::CREATED,
"la tarea tiene que darse de alta antes de editarla: contestó {status} con {alta}"
);
let id = alta["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador de la tarea")
.to_owned();
let largo = "n".repeat(121);
let mut edicion = cuerpo_de_tarea_con_nombre(&target, &repositorio, &largo);
edicion["keep_daily"] = serde_json::json!(3);
let respuesta = put_json(router.clone(), &format!("/api/v1/jobs/{id}"), &edicion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un nombre de 121 caracteres tiene que rechazarse también al editar: contestó {status} \
con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto");
assert!(
mensaje.contains("name"),
"el rechazo de la edición tiene que nombrar el campo `name`, y el mensaje fue \
{mensaje:?}"
);
assert!(
!mensaje.contains(&largo),
"el rechazo nombra el campo, nunca el valor que traía, y el mensaje fue {mensaje:?}"
);
let lectura = body_json(get(router, &format!("/api/v1/jobs/{id}")).await).await;
assert_eq!(
lectura["data"]["name"], "copia nocturna",
"una edición rechazada no puede haber escrito el nombre nuevo: {lectura}"
);
assert_eq!(
lectura["data"]["keep_daily"],
serde_json::json!(7),
"ni ninguno de los otros campos que traía el cuerpo: {lectura}"
);
}
}