use sqlx::Row;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteRow;
use time::{OffsetDateTime, UtcOffset};
use uuid::Uuid;
use crate::db::DbError;
use crate::db::entities::execution::Estado;
use crate::db::entities::target::TipoDeTarget;
#[derive(Debug, Clone)]
pub struct Restauracion {
pub id: Uuid,
pub snapshot_id: String,
pub target_id: Option<Uuid>,
pub status: Estado,
pub error_message: Option<String>,
pub started_at: OffsetDateTime,
pub finished_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone)]
pub struct AltaDeRestauracion {
pub snapshot_id: String,
pub target_id: Option<Uuid>,
pub started_at: OffsetDateTime,
}
pub struct Restauraciones {
pool: SqlitePool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FiltroDeRestauraciones {
pub target_id: Option<Uuid>,
pub status: Option<Estado>,
pub limite: i64,
pub desplazamiento: i64,
}
#[derive(Debug, Clone)]
pub struct RestauracionListada {
pub restauracion: Restauracion,
pub target_name: Option<String>,
pub target_type: Option<TipoDeTarget>,
}
#[derive(Debug, Clone)]
pub struct PaginaDeRestauraciones {
pub filas: Vec<RestauracionListada>,
pub total: i64,
}
const SQL_LEER: &str = "SELECT id, snapshot_id, target_id, status, error_message, started_at, \
finished_at FROM restore_executions WHERE id = ?1";
const SQL_ALTA: &str = "INSERT INTO restore_executions \
(id, snapshot_id, target_id, status, error_message, started_at, finished_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
RETURNING id, snapshot_id, target_id, status, error_message, started_at, finished_at";
const SQL_LISTAR_FILTRADO: &str = "SELECT r.id, r.snapshot_id, r.target_id, r.status, \
r.error_message, r.started_at, r.finished_at, t.name AS target_name, \
t.target_type AS target_type FROM restore_executions r \
LEFT JOIN backup_targets t ON t.id = r.target_id \
WHERE (?1 IS NULL OR r.target_id = ?1) AND (?2 IS NULL OR r.status = ?2) \
ORDER BY r.started_at DESC, r.id LIMIT ?3 OFFSET ?4";
const SQL_CONTAR_FILTRADO: &str = "SELECT COUNT(*) FROM restore_executions \
WHERE (?1 IS NULL OR target_id = ?1) AND (?2 IS NULL OR status = ?2)";
const SQL_ACTUALIZAR: &str =
"UPDATE restore_executions SET status = ?1, error_message = ?2, finished_at = ?3 WHERE id = ?4";
const CONSULTA_MAPEO: &str = "mapeo de la fila de restore_executions";
impl Restauraciones {
pub fn nuevo(pool: SqlitePool) -> Self {
Restauraciones { pool }
}
pub async fn alta(&self, alta: AltaDeRestauracion) -> Result<Restauracion, DbError> {
let id = Uuid::new_v4();
let fila = sqlx::query(SQL_ALTA)
.bind(id.hyphenated().to_string())
.bind(&alta.snapshot_id)
.bind(alta.target_id.map(|id| id.hyphenated().to_string()))
.bind(Estado::Pending.texto())
.bind(Option::<String>::None)
.bind(alta.started_at.to_offset(UtcOffset::UTC))
.bind(Option::<OffsetDateTime>::None)
.fetch_one(&self.pool)
.await
.map_err(error_de_consulta(SQL_ALTA))?;
desde_fila(&fila)
}
pub async fn leer(&self, id: &Uuid) -> Result<Option<Restauracion>, DbError> {
let fila = sqlx::query(SQL_LEER)
.bind(id.hyphenated().to_string())
.fetch_optional(&self.pool)
.await
.map_err(error_de_consulta(SQL_LEER))?;
match fila {
Some(fila) => desde_fila(&fila).map(Some),
None => Ok(None),
}
}
pub async fn listar_filtrado(
&self,
filtro: &FiltroDeRestauraciones,
) -> Result<PaginaDeRestauraciones, DbError> {
let filas = sqlx::query(SQL_LISTAR_FILTRADO)
.bind(filtro.target_id.map(|id| id.hyphenated().to_string()))
.bind(filtro.status.map(Estado::texto))
.bind(filtro.limite)
.bind(filtro.desplazamiento)
.fetch_all(&self.pool)
.await
.map_err(error_de_consulta(SQL_LISTAR_FILTRADO))?;
let filas = filas
.iter()
.map(desde_fila_listada)
.collect::<Result<_, _>>()?;
let total = sqlx::query_scalar(SQL_CONTAR_FILTRADO)
.bind(filtro.target_id.map(|id| id.hyphenated().to_string()))
.bind(filtro.status.map(Estado::texto))
.fetch_one(&self.pool)
.await
.map_err(error_de_consulta(SQL_CONTAR_FILTRADO))?;
Ok(PaginaDeRestauraciones { filas, total })
}
pub async fn actualizar(&self, restauracion: &Restauracion) -> Result<bool, DbError> {
let resultado = sqlx::query(SQL_ACTUALIZAR)
.bind(restauracion.status.texto())
.bind(&restauracion.error_message)
.bind(
restauracion
.finished_at
.map(|fecha| fecha.to_offset(UtcOffset::UTC)),
)
.bind(restauracion.id.hyphenated().to_string())
.execute(&self.pool)
.await
.map_err(error_de_consulta(SQL_ACTUALIZAR))?;
Ok(resultado.rows_affected() == 1)
}
}
fn error_de_consulta(consulta: &'static str) -> impl FnOnce(sqlx::Error) -> DbError {
move |causa| DbError::Consulta { consulta, causa }
}
fn columna<'fila, T>(fila: &'fila SqliteRow, nombre: &str) -> Result<T, DbError>
where
T: sqlx::Decode<'fila, sqlx::Sqlite> + sqlx::Type<sqlx::Sqlite>,
{
fila.try_get::<T, _>(nombre)
.map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa,
})
}
fn uuid_de_la_columna(fila: &SqliteRow, nombre: &str) -> Result<Uuid, DbError> {
let texto = columna::<String>(fila, nombre)?;
Uuid::parse_str(&texto).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})
}
fn desde_fila(fila: &SqliteRow) -> Result<Restauracion, DbError> {
let target_id = columna::<Option<String>>(fila, "target_id")?
.map(|texto| {
Uuid::parse_str(&texto).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})
})
.transpose()?;
Ok(Restauracion {
id: uuid_de_la_columna(fila, "id")?,
snapshot_id: columna(fila, "snapshot_id")?,
target_id,
status: columna(fila, "status")?,
error_message: columna(fila, "error_message")?,
started_at: columna(fila, "started_at")?,
finished_at: columna(fila, "finished_at")?,
})
}
fn desde_fila_listada(fila: &SqliteRow) -> Result<RestauracionListada, DbError> {
Ok(RestauracionListada {
restauracion: desde_fila(fila)?,
target_name: columna(fila, "target_name")?,
target_type: columna(fila, "target_type")?,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use sqlx::SqlitePool;
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use super::{
AltaDeRestauracion, Estado, FiltroDeRestauraciones, PaginaDeRestauraciones, Restauracion,
Restauraciones,
};
use crate::db::crypto::Clave;
use crate::db::entities::target::{AltaDeTarget, Targets, TipoDeTarget};
use crate::test_support::{DirectorioDePrueba, base_con_clave};
const INSTANTANEA: &str = "instantanea-distinguible-000000000000000000000001";
fn alta_target(nombre: &str) -> AltaDeTarget {
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,
}
}
fn alta_restauracion(target_id: Option<Uuid>) -> AltaDeRestauracion {
AltaDeRestauracion {
snapshot_id: INSTANTANEA.to_owned(),
target_id,
started_at: OffsetDateTime::now_utc(),
}
}
async fn base_con_restauracion_posible() -> (
DirectorioDePrueba,
SqlitePool,
Restauraciones,
Uuid,
Arc<Clave>,
) {
let (directorio, pool, clave) = base_con_clave().await;
let target = Targets::nuevo(pool.clone(), clave.clone())
.alta(alta_target("target-de-restauracion"))
.await
.expect("el alta del target no puede fallar");
let restauraciones = Restauraciones::nuevo(pool.clone());
(directorio, pool, restauraciones, target.id, clave)
}
async fn filas(pool: &SqlitePool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM restore_executions")
.fetch_one(pool)
.await
.expect("el recuento de filas debe poder leerse")
}
async fn definicion_de_la_tabla(pool: &SqlitePool) -> String {
sqlx::query_scalar(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'restore_executions'",
)
.fetch_one(pool)
.await
.expect("la definición de restore_executions debe poder leerse")
}
struct Columna {
nombre: String,
notnull: i64,
por_defecto: Option<String>,
clave_primaria: i64,
}
async fn columnas(pool: &SqlitePool) -> Vec<Columna> {
sqlx::query("PRAGMA table_info(restore_executions)")
.fetch_all(pool)
.await
.expect("el `PRAGMA table_info` debe poder leerse")
.iter()
.map(|fila| Columna {
nombre: sqlx::Row::try_get(fila, "name").expect("la columna tiene nombre"),
notnull: sqlx::Row::try_get(fila, "notnull")
.expect("la columna declara si es nula"),
por_defecto: sqlx::Row::try_get(fila, "dflt_value")
.expect("la columna declara su valor por defecto"),
clave_primaria: sqlx::Row::try_get(fila, "pk")
.expect("la columna declara si es clave primaria"),
})
.collect()
}
async fn columnas_del_indice(pool: &SqlitePool, indice: &str) -> Vec<String> {
sqlx::query(&format!("PRAGMA index_info('{indice}')"))
.fetch_all(pool)
.await
.expect("el `PRAGMA index_info` debe poder leerse")
.iter()
.map(|fila| {
sqlx::Row::try_get(fila, "name").expect("la columna del índice tiene nombre")
})
.collect()
}
#[tokio::test]
async fn la_migracion_crea_la_tabla_sus_columnas_su_check_su_clave_foranea_y_su_indice() {
let (_directorio, pool, _clave) = base_con_clave().await;
let columnas = columnas(&pool).await;
let esperadas: [(&str, i64, Option<&str>, i64); 7] = [
("id", 1, None, 1),
("snapshot_id", 1, None, 0),
("target_id", 0, None, 0),
("status", 1, Some("'pending'"), 0),
("error_message", 0, None, 0),
("started_at", 1, None, 0),
("finished_at", 0, None, 0),
];
assert_eq!(
columnas.len(),
esperadas.len(),
"la tabla tiene un número de columnas distinto del contrato: {:?}",
columnas.iter().map(|c| &c.nombre).collect::<Vec<_>>()
);
for (columna, (nombre, notnull, por_defecto, clave_primaria)) in
columnas.iter().zip(esperadas)
{
assert_eq!(
columna.nombre, nombre,
"el orden de las columnas no es el de la 0006"
);
assert_eq!(
columna.notnull, notnull,
"la columna `{nombre}` no declara la nulabilidad del contrato"
);
assert_eq!(
columna.por_defecto.as_deref(),
por_defecto,
"la columna `{nombre}` no declara el valor por defecto del contrato"
);
assert_eq!(
columna.clave_primaria, clave_primaria,
"la columna `{nombre}` no declara la clave primaria del contrato"
);
}
let definicion = definicion_de_la_tabla(&pool).await;
assert!(
definicion.contains("CHECK(status IN ('pending', 'running', 'success', 'failed'))"),
"el `CHECK` de `status` no es el del contrato: {definicion}"
);
let claves = sqlx::query("PRAGMA foreign_key_list(restore_executions)")
.fetch_all(&pool)
.await
.expect("el `PRAGMA foreign_key_list` debe poder leerse");
assert_eq!(
claves.len(),
1,
"la tabla debe tener exactamente una clave foránea"
);
let clave = &claves[0];
let tabla_destino: String =
sqlx::Row::try_get(clave, "table").expect("la clave foránea nombra su tabla");
let columna_hija: String =
sqlx::Row::try_get(clave, "from").expect("la clave foránea nombra su columna hija");
let columna_padre: String =
sqlx::Row::try_get(clave, "to").expect("la clave foránea nombra su columna padre");
let al_borrar: String =
sqlx::Row::try_get(clave, "on_delete").expect("la clave foránea declara su borrado");
assert_eq!(
tabla_destino, "backup_targets",
"la clave foránea apunta a otra tabla"
);
assert_eq!(
columna_hija, "target_id",
"la clave foránea sale de otra columna"
);
assert_eq!(
columna_padre, "id",
"la clave foránea no apunta al `id` del target"
);
assert_eq!(
al_borrar, "CASCADE",
"el borrado del target no arrastra el histórico"
);
let existe: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' \
AND name = 'idx_restore_executions_status_started_at'",
)
.fetch_one(&pool)
.await
.expect("el catálogo debe poder leerse");
assert_eq!(existe, 1, "falta el índice del listado por estado y fecha");
assert_eq!(
columnas_del_indice(&pool, "idx_restore_executions_status_started_at").await,
vec!["status".to_owned(), "started_at".to_owned()],
"el índice no cubre la pareja (status, started_at) del contrato"
);
}
#[tokio::test]
async fn un_status_fuera_del_enumerado_lo_rechaza_el_check() {
let (_directorio, pool, _restauraciones, target, _clave) =
base_con_restauracion_posible().await;
let resultado = sqlx::query(
"INSERT INTO restore_executions (id, snapshot_id, target_id, status, started_at) \
VALUES (?1, ?2, ?3, 'cancelada', ?4)",
)
.bind(Uuid::new_v4().hyphenated().to_string())
.bind(INSTANTANEA)
.bind(target.hyphenated().to_string())
.bind(datetime!(2026-03-01 10:00:00 UTC))
.execute(&pool)
.await;
let error = resultado.expect_err("un status fuera del enumerado tiene que rechazarse");
assert!(
error.to_string().contains("CHECK"),
"la escritura falló, pero no por el `CHECK`: {error}"
);
assert_eq!(filas(&pool).await, 0, "el rechazo dejó una fila escrita");
}
#[tokio::test]
async fn borrar_el_target_borra_su_historico() {
let (_directorio, pool, restauraciones, target, clave) =
base_con_restauracion_posible().await;
let encendidas: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(&pool)
.await
.expect("el estado de las claves foráneas debe poder consultarse");
assert_eq!(
encendidas, 1,
"el pool de prueba no tiene las claves foráneas encendidas: la cascada no ocurriría"
);
let creada = restauraciones
.alta(alta_restauracion(Some(target)))
.await
.expect("el alta no puede fallar");
assert_eq!(
filas(&pool).await,
1,
"el alta no dejó la fila que la cascada tiene que arrastrar"
);
let borrado = Targets::nuevo(pool.clone(), clave.clone())
.borrar(&target)
.await
.expect("el borrado del target no puede fallar");
assert!(
borrado,
"el target existía, así que el borrado debe decir que sí"
);
assert_eq!(
filas(&pool).await,
0,
"borrar el target dejó su histórico de restauraciones huérfano"
);
assert!(
restauraciones
.leer(&creada.id)
.await
.expect("la lectura no puede fallar")
.is_none(),
"la fila de la restauración sobrevivió al borrado de su target"
);
}
#[tokio::test]
async fn el_alta_deja_la_fila_en_pending_sin_final_ni_motivo() {
let (_directorio, pool, restauraciones, target, _clave) =
base_con_restauracion_posible().await;
let creada = restauraciones
.alta(alta_restauracion(Some(target)))
.await
.expect("el alta no puede fallar");
assert_eq!(
creada.status,
Estado::Pending,
"una restauración nace encolada"
);
assert_eq!(creada.snapshot_id, INSTANTANEA);
assert_eq!(creada.target_id, Some(target));
assert_eq!(creada.error_message, None);
assert_eq!(creada.finished_at, None);
assert_eq!(
creada.id.hyphenated().to_string().len(),
36,
"el identificador no se guardó en forma canónica con guiones"
);
let leida = restauraciones
.leer(&creada.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila recién dada de alta debe estar");
assert_eq!(leida.id, creada.id, "el id leído no es el del alta");
assert_eq!(leida.status, Estado::Pending);
assert_eq!(
leida.started_at, creada.started_at,
"el inicio no volvió igual"
);
let estado_almacenado: String =
sqlx::query_scalar("SELECT status FROM restore_executions WHERE id = ?1")
.bind(creada.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el estado debe poder leerse");
assert_eq!(
estado_almacenado, "pending",
"el enumerado no se guardó como texto, sino como {estado_almacenado}"
);
let nulos: i64 = sqlx::query_scalar(
"SELECT (finished_at IS NULL) + (error_message IS NULL) FROM restore_executions \
WHERE id = ?1",
)
.bind(creada.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el `IS NULL` debe poder leerse");
assert_eq!(
nulos, 2,
"una restauración encolada no tiene final ni motivo de fallo"
);
let sin_target = restauraciones
.alta(alta_restauracion(None))
.await
.expect("un alta sin target no puede fallar");
assert_eq!(sin_target.target_id, None);
assert_ne!(
sin_target.id, creada.id,
"dos altas no pueden compartir identificador"
);
assert_eq!(
restauraciones
.leer(&sin_target.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila sin target debe estar")
.target_id,
None,
"el `NULL` de `target_id` no volvió como `None`"
);
}
#[tokio::test]
async fn una_fecha_con_desplazamiento_vuelve_en_utc() {
let (_directorio, _pool, restauraciones, target, _clave) =
base_con_restauracion_posible().await;
let inicio = datetime!(2026-03-14 09:26:53 +2:30);
assert!(
!inicio.offset().is_utc(),
"el aparato de la prueba no usó un desplazamiento distinto de cero"
);
let creada = restauraciones
.alta(AltaDeRestauracion {
started_at: inicio,
..alta_restauracion(Some(target))
})
.await
.expect("el alta no puede fallar");
let leida = restauraciones
.leer(&creada.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar");
assert!(
leida.started_at.offset().is_utc(),
"el started_at volvió con desplazamiento, no en UTC: {}",
leida.started_at
);
assert_eq!(
leida.started_at, inicio,
"el instante guardado no es el de entrada"
);
}
#[tokio::test]
async fn actualizar_a_success_escribe_el_final() {
let (_directorio, pool, restauraciones, target, _clave) =
base_con_restauracion_posible().await;
let creada = restauraciones
.alta(alta_restauracion(Some(target)))
.await
.expect("el alta no puede fallar");
let mut terminada = creada.clone();
terminada.status = Estado::Success;
terminada.finished_at = Some(creada.started_at + Duration::seconds(30));
assert!(
restauraciones
.actualizar(&terminada)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let releida = restauraciones
.leer(&creada.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
releida.status,
Estado::Success,
"el estado tocado no cambió"
);
assert_eq!(
releida.finished_at, terminada.finished_at,
"el final no se escribió, o no volvió igual"
);
assert_eq!(
releida.error_message, None,
"un éxito no tiene motivo de fallo"
);
assert_eq!(
releida.started_at, creada.started_at,
"el started_at no debía cambiar"
);
assert_eq!(
releida.snapshot_id, creada.snapshot_id,
"el snapshot_id no debía cambiar"
);
assert_eq!(
releida.target_id, creada.target_id,
"el target_id no debía cambiar"
);
let final_escrito: i64 = sqlx::query_scalar(
"SELECT (status = 'success') + (finished_at IS NOT NULL) FROM restore_executions \
WHERE id = ?1",
)
.bind(creada.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("las columnas deben poder leerse");
assert_eq!(
final_escrito, 2,
"la tabla no quedó con `success` y el final escrito"
);
let fantasma = Restauracion {
id: Uuid::new_v4(),
..creada
};
assert!(
!restauraciones
.actualizar(&fantasma)
.await
.expect("actualizar una fila que no existe no puede fallar"),
"una actualización sobre una fila que no existe no puede decir que sí"
);
}
#[tokio::test]
async fn actualizar_a_failed_escribe_el_motivo_y_el_final() {
let (_directorio, pool, restauraciones, target, _clave) =
base_con_restauracion_posible().await;
let creada = restauraciones
.alta(alta_restauracion(Some(target)))
.await
.expect("el alta no puede fallar");
let mut fallida = creada.clone();
fallida.status = Estado::Failed;
fallida.error_message = Some("fallo distinguible de la prueba 0-2".to_owned());
fallida.finished_at = Some(creada.started_at + Duration::minutes(1));
assert!(
restauraciones
.actualizar(&fallida)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let releida = restauraciones
.leer(&creada.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
releida.status,
Estado::Failed,
"la restauración no quedó fallida"
);
assert_eq!(
releida.error_message.as_deref(),
Some("fallo distinguible de la prueba 0-2"),
"el motivo del fallo no se escribió"
);
assert_eq!(releida.finished_at, fallida.finished_at);
assert_ne!(
releida.status,
Estado::Running,
"un fallo cerrado no puede quedarse en `running`"
);
let motivo_escrito: String =
sqlx::query_scalar("SELECT error_message FROM restore_executions WHERE id = ?1")
.bind(creada.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el motivo debe poder leerse");
assert_eq!(motivo_escrito, "fallo distinguible de la prueba 0-2");
}
#[tokio::test]
async fn el_listado_filtrado_acota_ordena_descendente_y_pagina_con_total() {
let (_directorio, pool, restauraciones, target_a, clave) =
base_con_restauracion_posible().await;
let target_b = Targets::nuevo(pool.clone(), clave.clone())
.alta(alta_target("target-b-de-restauracion"))
.await
.expect("el alta del segundo target no puede fallar")
.id;
let empate = datetime!(2026-03-01 10:00:00 UTC);
let intermedia = empate - Duration::hours(1);
let anterior = empate - Duration::hours(2);
let mut identificadores = Vec::new();
for (target, estado, fecha) in [
(target_a, Estado::Success, anterior),
(target_a, Estado::Failed, empate),
(target_b, Estado::Success, empate),
(target_b, Estado::Running, intermedia),
] {
let mut fila = restauraciones
.alta(AltaDeRestauracion {
target_id: Some(target),
started_at: fecha,
..alta_restauracion(Some(target))
})
.await
.expect("el alta de la restauración no puede fallar");
fila.status = estado;
assert!(
restauraciones
.actualizar(&fila)
.await
.expect("la actualización no puede fallar"),
"la fila recién dada de alta tiene que poder actualizarse"
);
identificadores.push(fila.id);
}
let sin_filtros = FiltroDeRestauraciones {
target_id: None,
status: None,
limite: 50,
desplazamiento: 0,
};
let PaginaDeRestauraciones { filas, total } = restauraciones
.listar_filtrado(&sin_filtros)
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(
total, 4,
"sin filtros el total tiene que ser el de la tabla"
);
assert_eq!(
filas.len(),
4,
"sin filtros tienen que salir las cuatro filas"
);
assert_eq!(
filas[3].restauracion.id, identificadores[0],
"la más antigua tiene que quedar la última"
);
assert_eq!(
filas[3].target_name.as_deref(),
Some("target-de-restauracion"),
"el listado tiene que resolver el nombre del target por su vínculo"
);
assert_eq!(
filas[3].target_type,
Some(TipoDeTarget::Directory),
"y su tipo, con el valor que guarda el `CHECK`"
);
let mut de_las_empatadas = [filas[0].restauracion.id, filas[1].restauracion.id];
de_las_empatadas.sort();
let mut esperadas = [identificadores[1], identificadores[2]];
esperadas.sort();
assert_eq!(
de_las_empatadas, esperadas,
"las dos filas que empatan en fecha tienen que ir delante, y en orden de identificador"
);
assert_eq!(
filas[2].restauracion.id, identificadores[3],
"la intermedia tiene que quedar entre las empatadas y la antigua"
);
let solo_fallidas = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
status: Some(Estado::Failed),
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(solo_fallidas.total, 1, "el total no contó las fallidas");
assert_eq!(
solo_fallidas.filas.len(),
1,
"el filtro por estado tiene que acotar"
);
assert_eq!(solo_fallidas.filas[0].restauracion.id, identificadores[1]);
let del_target_b = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
target_id: Some(target_b),
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(del_target_b.total, 2, "el total no acotó por target");
assert!(
del_target_b
.filas
.iter()
.all(|fila| fila.restauracion.target_id == Some(target_b)),
"el filtro por target trajo filas de otro target"
);
let de_b_en_curso = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
target_id: Some(target_b),
status: Some(Estado::Running),
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(de_b_en_curso.total, 1, "los dos filtros no se combinaron");
assert_eq!(de_b_en_curso.filas[0].restauracion.id, identificadores[3]);
let de_otro_target = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
target_id: Some(Uuid::new_v4()),
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(
de_otro_target.total, 0,
"un filtro sin filas tiene que dar total cero"
);
assert!(
de_otro_target.filas.is_empty(),
"un filtro por un target sin restauraciones no puede devolver filas"
);
let primera_pagina = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
limite: 2,
desplazamiento: 0,
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(
primera_pagina.filas.len(),
2,
"la ventana tiene que devolver dos filas"
);
assert_eq!(
primera_pagina.total, 4,
"el total no puede encogerse con la ventana"
);
assert_eq!(
primera_pagina.filas[0].restauracion.id, filas[0].restauracion.id,
"la primera página no empieza por la primera fila del orden"
);
let segunda_pagina = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
limite: 2,
desplazamiento: 2,
..sin_filtros
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(
segunda_pagina.filas.len(),
2,
"la segunda página tiene que traer las restantes"
);
assert_eq!(segunda_pagina.total, 4, "el total no cambia entre páginas");
assert_eq!(
segunda_pagina.filas[0].restauracion.id, filas[2].restauracion.id,
"la segunda página tiene que empezar donde acaba la primera"
);
assert_eq!(
segunda_pagina.filas[1].restauracion.id,
filas[3].restauracion.id
);
let mut paginadas: Vec<Uuid> = primera_pagina
.filas
.iter()
.chain(segunda_pagina.filas.iter())
.map(|fila| fila.restauracion.id)
.collect();
let mut completas: Vec<Uuid> = filas.iter().map(|fila| fila.restauracion.id).collect();
paginadas.sort();
completas.sort();
assert_eq!(
paginadas, completas,
"las dos páginas juntas no son el listado entero: se repitió o se perdió una fila"
);
}
#[tokio::test]
async fn el_listado_publica_el_target_y_null_cuando_la_fila_no_lo_tiene() {
let (_directorio, _pool, restauraciones, target, _clave) =
base_con_restauracion_posible().await;
restauraciones
.alta(alta_restauracion(Some(target)))
.await
.expect("el alta con target no puede fallar");
restauraciones
.alta(alta_restauracion(None))
.await
.expect("el alta sin target no puede fallar");
let pagina = restauraciones
.listar_filtrado(&FiltroDeRestauraciones {
target_id: None,
status: None,
limite: 50,
desplazamiento: 0,
})
.await
.expect("el listado filtrado no puede fallar");
assert_eq!(
pagina.total, 2,
"las dos filas tienen que seguir listándose"
);
let con_target = pagina
.filas
.iter()
.find(|fila| fila.restauracion.target_id.is_some())
.expect("la fila con target tiene que estar en el listado");
assert_eq!(
con_target.target_name.as_deref(),
Some("target-de-restauracion"),
"una fila con target tiene que traer su nombre"
);
assert_eq!(
con_target.target_type,
Some(TipoDeTarget::Directory),
"y su tipo"
);
let sin_target = pagina
.filas
.iter()
.find(|fila| fila.restauracion.target_id.is_none())
.expect("la fila sin target tiene que estar en el listado");
assert_eq!(
sin_target.target_name, None,
"una fila sin target no puede inventarse un nombre"
);
assert_eq!(
sin_target.target_type, None,
"ni un tipo: los dos son huecos y viajan como `null`"
);
}
}