use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::api::contenido;
use crate::api::extract::Query;
use crate::api::response::ApiResponse;
use crate::api::targets::texto_del_tipo;
use crate::db::entities::{
Estado, FiltroDeRestauraciones, PaginaDeRestauraciones, RestauracionListada,
};
use crate::error::AppError;
use crate::state::AppState;
pub const LIMITE_POR_DEFECTO: i64 = 50;
pub const LIMITE_MAXIMO: i64 = 200;
pub fn router() -> Router<AppState> {
Router::new().route("/api/v1/restorations", get(listado))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FiltrosDelCable {
pub status: Option<String>,
pub target_id: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
impl FiltrosDelCable {
fn validar(self) -> Result<FiltroDeRestauraciones, AppError> {
let status = match self.status.as_deref() {
Some(valor) => Some(Estado::desde_texto(valor).ok_or_else(|| {
AppError::InvalidInput(format!(
"el campo `status` de la consulta no es uno de los cuatro estados admitidos: {}",
estados_admitidos()
))
})?),
None => None,
};
let target_id = match self.target_id.as_deref() {
Some(valor) => Some(Uuid::parse_str(valor).map_err(|_| {
AppError::InvalidInput(
"el campo `target_id` de la consulta no tiene la forma de un UUID".to_owned(),
)
})?),
None => None,
};
let limite = match self.limit {
Some(valor) if !(1..=LIMITE_MAXIMO).contains(&valor) => {
return Err(AppError::InvalidInput(format!(
"el campo `limit` de la consulta tiene que ser un número entre 1 y {LIMITE_MAXIMO}"
)));
}
Some(valor) => valor,
None => LIMITE_POR_DEFECTO,
};
let desplazamiento = match self.offset {
Some(valor) if valor < 0 => {
return Err(AppError::InvalidInput(
"el campo `offset` de la consulta no puede ser negativo".to_owned(),
));
}
Some(valor) => valor,
None => 0,
};
Ok(FiltroDeRestauraciones {
target_id,
status,
limite,
desplazamiento,
})
}
}
fn estados_admitidos() -> String {
[
Estado::Pending,
Estado::Running,
Estado::Success,
Estado::Failed,
]
.map(Estado::texto)
.join(", ")
}
#[derive(Debug, Serialize)]
pub struct RestauracionDelCable {
pub id: String,
pub snapshot_id: String,
pub target_id: Option<String>,
pub target_name: Option<String>,
pub target_type: Option<String>,
pub status: String,
pub error_message: Option<String>,
pub started_at: String,
pub finished_at: Option<String>,
}
impl RestauracionDelCable {
fn desde(fila: &RestauracionListada) -> Result<Self, AppError> {
Ok(Self {
id: fila.restauracion.id.hyphenated().to_string(),
snapshot_id: fila.restauracion.snapshot_id.clone(),
target_id: fila
.restauracion
.target_id
.map(|id| id.hyphenated().to_string()),
target_name: fila.target_name.clone(),
target_type: fila.target_type.map(|tipo| texto_del_tipo(tipo).to_owned()),
status: fila.restauracion.status.texto().to_owned(),
error_message: fila.restauracion.error_message.clone(),
started_at: formatear(fila.restauracion.started_at)?,
finished_at: fila.restauracion.finished_at.map(formatear).transpose()?,
})
}
}
fn formatear(fecha: time::OffsetDateTime) -> Result<String, AppError> {
fecha.format(&Rfc3339).map_err(|error| {
AppError::Other(format!("la fecha de la fila no se pudo formatear: {error}"))
})
}
#[derive(Debug, Serialize)]
struct ListadoDelCable {
restorations: Vec<RestauracionDelCable>,
total: i64,
}
pub async fn listado(
State(estado): State<AppState>,
Query(filtros): Query<FiltrosDelCable>,
) -> Result<ApiResponse, AppError> {
let filtro = filtros.validar()?;
let PaginaDeRestauraciones { filas, total } =
estado.db.restauraciones.listar_filtrado(&filtro).await?;
let restorations = filas
.iter()
.map(RestauracionDelCable::desde)
.collect::<Result<_, _>>()?;
Ok(ApiResponse::new(
StatusCode::OK,
"restauraciones listadas",
contenido(&ListadoDelCable {
restorations,
total,
})?,
))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use crate::api::api_router;
use crate::db::entities::{
AltaDeRestauracion, AltaDeTarget, Estado, Restauraciones, Target, TipoDeTarget,
};
use crate::state::{AppState, EstadoDeBase};
use crate::test_support::{
DirectorioDePrueba, base_con_clave, body_json, config_con_dsn, estado_de_prueba, get,
router_oidc,
};
async fn estado_con_base() -> (AppState, DirectorioDePrueba) {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool, Arc::clone(&clave)),
);
(estado, directorio)
}
async fn sembrar_target(estado: &AppState, nombre: &str) -> Target {
estado
.db
.targets
.alta(AltaDeTarget {
name: nombre.to_owned(),
target_type: TipoDeTarget::Directory,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: Some(format!("/srv/{nombre}")),
db_path: None,
})
.await
.expect("el target de prueba se da de alta")
}
async fn sembrar_restauracion(
restauraciones: &Restauraciones,
target_id: Option<Uuid>,
status: Estado,
snapshot_id: &str,
started_at: OffsetDateTime,
) -> Uuid {
let mut fila = restauraciones
.alta(AltaDeRestauracion {
snapshot_id: snapshot_id.to_owned(),
target_id,
started_at,
})
.await
.expect("la restauración de prueba se da de alta");
fila.status = status;
fila.finished_at = matches!(status, Estado::Success | Estado::Failed)
.then_some(started_at + Duration::seconds(30));
assert!(
restauraciones
.actualizar(&fila)
.await
.expect("la actualización no puede fallar"),
"la fila recién dada de alta tiene que poder actualizarse"
);
fila.id
}
#[tokio::test]
async fn el_listado_devuelve_las_restauraciones_con_sus_datos() {
let (estado, _directorio) = estado_con_base().await;
let target = sembrar_target(&estado, "target-del-historico").await;
let base = datetime!(2026-10-25 03:00:00 UTC);
sembrar_restauracion(
&estado.db.restauraciones,
Some(target.id),
Estado::Success,
"instantanea-vieja-0000000000000000000001",
base,
)
.await;
sembrar_restauracion(
&estado.db.restauraciones,
Some(target.id),
Estado::Failed,
"instantanea-reciente-00000000000000000001",
base + Duration::hours(1),
)
.await;
let respuesta = get(api_router(estado), "/api/v1/restorations").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado contesta {status}: {cuerpo}"
);
assert_eq!(cuerpo["data"]["total"], 2, "el total son las dos filas");
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado tiene que llegar como un array");
assert_eq!(filas.len(), 2, "sin filtros salen las dos filas");
assert_eq!(
filas[0]["snapshot_id"], "instantanea-reciente-00000000000000000001",
"el orden es descendente por la fecha de comienzo y la más reciente va primero: {cuerpo}"
);
assert_eq!(
filas[1]["snapshot_id"], "instantanea-vieja-0000000000000000000001",
"la más antigua tiene que quedar la última: {cuerpo}"
);
for clave in ["id", "status", "started_at", "finished_at"] {
assert!(
filas[0].get(clave).is_some(),
"cada fila tiene que publicar `{clave}`: {cuerpo}"
);
}
assert_eq!(filas[0]["status"], "failed", "y el estado es el de la fila");
assert_eq!(
filas[0]["target_id"].as_str(),
Some(target.id.hyphenated().to_string().as_str()),
"cada fila tiene que publicar el target sobre el que se restauró: {cuerpo}"
);
assert_eq!(
filas[0]["target_name"].as_str(),
Some("target-del-historico"),
"y su nombre, resuelto por el vínculo: {cuerpo}"
);
assert_eq!(
filas[0]["target_type"].as_str(),
Some("directory"),
"y su tipo, con el mismo texto del cable que `/api/v1/targets`: {cuerpo}"
);
let started_at = filas[0]["started_at"]
.as_str()
.expect("la fecha de comienzo tiene que ser una cadena");
assert!(
started_at.ends_with('Z') || started_at.contains('+'),
"la fecha tiene que publicarse en RFC3339: {started_at}"
);
}
#[tokio::test]
async fn el_filtro_por_estado_acota_el_listado() {
let (estado, _directorio) = estado_con_base().await;
let target = sembrar_target(&estado, "target-del-filtro").await;
let base = datetime!(2026-10-25 03:00:00 UTC);
for (indice, estado_fila) in [
Estado::Success,
Estado::Failed,
Estado::Failed,
Estado::Running,
]
.into_iter()
.enumerate()
{
sembrar_restauracion(
&estado.db.restauraciones,
Some(target.id),
estado_fila,
&format!("instantanea-{indice:02}-00000000000000000000000000"),
base + Duration::minutes(indice as i64),
)
.await;
}
let respuesta = get(api_router(estado), "/api/v1/restorations?status=failed").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el filtro por estado contesta {status}: {cuerpo}"
);
assert_eq!(cuerpo["data"]["total"], 2, "solo dos filas fallaron");
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado es un array");
assert_eq!(filas.len(), 2, "el filtro tiene que acotar a las fallidas");
for fila in filas {
assert_eq!(
fila["status"], "failed",
"el filtro no puede traer otra cosa"
);
}
}
#[tokio::test]
async fn el_filtro_por_target_acota_el_listado() {
let (estado, _directorio) = estado_con_base().await;
let target_a = sembrar_target(&estado, "target-a").await;
let target_b = sembrar_target(&estado, "target-b").await;
let base = datetime!(2026-10-25 03:00:00 UTC);
sembrar_restauracion(
&estado.db.restauraciones,
Some(target_a.id),
Estado::Success,
"instantanea-de-a-000000000000000000000000",
base,
)
.await;
sembrar_restauracion(
&estado.db.restauraciones,
Some(target_b.id),
Estado::Success,
"instantanea-de-b-000000000000000000000000",
base + Duration::minutes(1),
)
.await;
let respuesta = get(
api_router(estado),
&format!("/api/v1/restorations?target_id={}", target_b.id),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el filtro por target contesta {status}: {cuerpo}"
);
assert_eq!(cuerpo["data"]["total"], 1, "el target B solo tiene una");
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado es un array");
assert_eq!(filas.len(), 1, "el filtro tiene que acotar");
assert_eq!(
filas[0]["target_id"].as_str(),
Some(target_b.id.hyphenated().to_string().as_str()),
"y la que vuelve es la del target B"
);
}
#[tokio::test]
async fn el_listado_pagina_de_forma_acotada_con_el_total() {
let (estado, _directorio) = estado_con_base().await;
let target = sembrar_target(&estado, "target-de-la-ventana").await;
let base = datetime!(2026-10-25 03:00:00 UTC);
for indice in 0..3_i64 {
sembrar_restauracion(
&estado.db.restauraciones,
Some(target.id),
Estado::Success,
&format!("instantanea-{indice:02}-00000000000000000000000000"),
base + Duration::minutes(indice),
)
.await;
}
let primera = get(
api_router(estado.clone()),
"/api/v1/restorations?limit=2&offset=0",
)
.await;
let status = primera.status();
let cuerpo = body_json(primera).await;
assert_eq!(
status,
StatusCode::OK,
"la primera página contesta {status}: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["total"], 3,
"el total no puede encogerse con la ventana"
);
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado es un array");
assert_eq!(filas.len(), 2, "la ventana acota a dos");
let primera_fila = filas[0]["snapshot_id"].clone();
let segunda = get(api_router(estado), "/api/v1/restorations?limit=2&offset=2").await;
let status = segunda.status();
let cuerpo = body_json(segunda).await;
assert_eq!(
status,
StatusCode::OK,
"la segunda página contesta {status}: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["total"], 3,
"el total no cambia entre páginas"
);
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado es un array");
assert_eq!(filas.len(), 1, "la segunda página trae la restante");
assert_ne!(
filas[0]["snapshot_id"], primera_fila,
"el `offset` tiene que avanzar y no repetir la primera página"
);
}
#[tokio::test]
async fn una_fila_sin_target_publica_su_target_a_null() {
let (estado, _directorio) = estado_con_base().await;
let target = sembrar_target(&estado, "target-con-nombre").await;
let base = datetime!(2026-10-25 03:00:00 UTC);
sembrar_restauracion(
&estado.db.restauraciones,
Some(target.id),
Estado::Success,
"instantanea-con-target-000000000000000000",
base,
)
.await;
sembrar_restauracion(
&estado.db.restauraciones,
None,
Estado::Success,
"instantanea-sin-target-000000000000000000",
base + Duration::minutes(1),
)
.await;
let respuesta = get(api_router(estado), "/api/v1/restorations").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado contesta {status}: {cuerpo}"
);
let filas = cuerpo["data"]["restorations"]
.as_array()
.expect("el listado es un array");
assert_eq!(filas.len(), 2, "las dos filas tienen que seguir listándose");
let sin_target = &filas[0];
let con_target = &filas[1];
assert_eq!(
sin_target["target_id"],
serde_json::Value::Null,
"la fila sin target no puede publicar un identificador: {cuerpo}"
);
assert_eq!(
sin_target["target_name"],
serde_json::Value::Null,
"ni un nombre inventado: {cuerpo}"
);
assert_eq!(
sin_target["target_type"],
serde_json::Value::Null,
"ni un tipo inventado: {cuerpo}"
);
assert_eq!(
con_target["target_name"].as_str(),
Some("target-con-nombre"),
"la fila con target sí trae su nombre: {cuerpo}"
);
}
#[tokio::test]
async fn un_status_fuera_del_vocabulario_contesta_400() {
let (estado, _directorio) = estado_con_base().await;
let respuesta = get(api_router(estado), "/api/v1/restorations?status=terminada").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un estado fuera del vocabulario tiene que contestar 400: {cuerpo}"
);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|motivo| motivo.contains("status") && !motivo.contains("terminada")),
"el 400 tiene que nombrar el campo y no repetir el valor: {cuerpo}"
);
}
#[tokio::test]
async fn el_listado_exige_sesion_en_modo_oidc() {
let respuesta = get(router_oidc().await, "/api/v1/restorations").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"sin sesión el listado tiene que exigirla y contestó {status}: {cuerpo}"
);
assert_eq!(cuerpo["status"], 401, "la envoltura del 401: {cuerpo}");
}
}