feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -77,3 +77,111 @@ def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
|
||||
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
|
||||
assert cfg["paths"]["input_folder"] == "/tmp/in"
|
||||
|
||||
|
||||
def _redirect_bootstrap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Reapunta los globales de bootstrap a un árbol temporal. Devuelve config/."""
|
||||
for name, rel in (
|
||||
("APP_DIR", ""),
|
||||
("CONFIG_DIR", "config"),
|
||||
("DATA_DIR", "config/data"),
|
||||
("LOGS_DIR", "config/logs"),
|
||||
("ODBC_DIR", "config/odbc"),
|
||||
("SEVEN_ZIP_DIR", "config/7zip"),
|
||||
("ENV_PATH", "config/.env"),
|
||||
("DIR_ENTRADA", "Entrada"),
|
||||
("DIR_PROCESADOS", "Procesados"),
|
||||
("DIR_FALLADOS", "Fallados"),
|
||||
("DIR_TEMP", "Temp"),
|
||||
):
|
||||
target = tmp_path / rel if rel else tmp_path
|
||||
monkeypatch.setattr(f"app.config.bootstrap.{name}", target)
|
||||
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
|
||||
return tmp_path / "config"
|
||||
|
||||
|
||||
def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
El instalador remoto del PANEL lee config/.version por SFTP para verificar el
|
||||
despliegue: en Windows el .exe se compila con console=False y no hay stdout confiable.
|
||||
"""
|
||||
from app import __version__
|
||||
|
||||
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
|
||||
ensure_runtime_layout()
|
||||
assert (config_dir / ".version").read_text(encoding="utf-8").strip() == __version__
|
||||
|
||||
|
||||
def test_bootstrap_redespliega_deps_cuando_cambia_el_manifiesto(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""
|
||||
config/7zip y config/odbc se re-copian cuando el build trae otras versiones embebidas.
|
||||
Antes solo se copiaban si la carpeta estaba vacía, así que una actualización con driver
|
||||
ODBC nuevo conservaba el viejo indefinidamente.
|
||||
"""
|
||||
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
|
||||
|
||||
src_7zip = tmp_path / "bundle" / "7zip"
|
||||
src_odbc = tmp_path / "bundle" / "odbc"
|
||||
src_7zip.mkdir(parents=True)
|
||||
src_odbc.mkdir(parents=True)
|
||||
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
|
||||
(src_odbc / "odbcinst.ini").write_text("odbc 18.5", encoding="utf-8")
|
||||
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
|
||||
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_ODBC", src_odbc)
|
||||
|
||||
# bundled-versions.json se busca bajo BUNDLE_DIR/packaging (va embebido en el onefile).
|
||||
bundle_root = tmp_path / "bundle_root"
|
||||
(bundle_root / "packaging").mkdir(parents=True)
|
||||
manifest = bundle_root / "packaging" / "bundled-versions.json"
|
||||
manifest.write_text('{"seven_zip": "26.01"}', encoding="utf-8")
|
||||
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
|
||||
|
||||
ensure_runtime_layout()
|
||||
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v26"
|
||||
stamp_before = (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip()
|
||||
assert stamp_before.startswith("sha256:")
|
||||
|
||||
# Mismo manifiesto: no debe re-copiar (no pisa ajustes locales en cada arranque).
|
||||
(config_dir / "7zip" / "7zz").write_text("editado a mano", encoding="utf-8")
|
||||
ensure_runtime_layout()
|
||||
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "editado a mano"
|
||||
|
||||
# Manifiesto distinto (build con deps nuevas): debe re-copiar.
|
||||
(src_7zip / "7zz").write_text("7zz v27", encoding="utf-8")
|
||||
(src_odbc / "odbcinst.ini").write_text("odbc 19.0", encoding="utf-8")
|
||||
manifest.write_text('{"seven_zip": "27.00"}', encoding="utf-8")
|
||||
ensure_runtime_layout()
|
||||
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v27"
|
||||
assert (config_dir / "odbc" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 19.0"
|
||||
assert (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() != stamp_before
|
||||
|
||||
# El re-despliegue SOBRESCRIBE, así que lo que el operador hubiera editado a mano debe
|
||||
# quedar respaldado: no es reconstruible.
|
||||
assert (config_dir / "7zip.bak" / "7zz").read_text(encoding="utf-8") == "editado a mano"
|
||||
assert (config_dir / "odbc.bak" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 18.5"
|
||||
|
||||
|
||||
def test_bootstrap_no_respalda_cuando_no_hay_refresco(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Sin cambio de manifiesto no se re-copia, así que tampoco debe crearse el .bak."""
|
||||
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
|
||||
|
||||
src_7zip = tmp_path / "bundle" / "7zip"
|
||||
src_7zip.mkdir(parents=True)
|
||||
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
|
||||
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
|
||||
|
||||
bundle_root = tmp_path / "bundle_root"
|
||||
(bundle_root / "packaging").mkdir(parents=True)
|
||||
(bundle_root / "packaging" / "bundled-versions.json").write_text(
|
||||
'{"seven_zip": "26.01"}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
|
||||
|
||||
ensure_runtime_layout()
|
||||
ensure_runtime_layout() # segunda vez: mismo manifiesto, no debe refrescar
|
||||
|
||||
assert not (config_dir / "7zip.bak").exists()
|
||||
|
||||
170
tests/test_forward_false_failure.py
Normal file
170
tests/test_forward_false_failure.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Regresión del "falso fallo" en el reenvío por SFTP.
|
||||
|
||||
Escenario reportado: el SFTP SÍ entrega el archivo, pero un error POSTERIOR a la entrega
|
||||
(p.ej. al mover el ZIP a Procesados) degradaba el job a FAILED y lo reportaba como 'failed'
|
||||
al panel, aunque el respaldo ya había llegado al destino. La corrección hace que la entrega
|
||||
exitosa sea el punto de no retorno: el job queda COMPLETED/forwarded y el error posterior
|
||||
solo se registra, sin caer a Fallados ni reportar 'failed'.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.constants import JobStatus
|
||||
from app.engine.restore_worker import RestoreWorker
|
||||
|
||||
|
||||
FORWARD_ROUTE = {
|
||||
"action": "forward",
|
||||
"db_name": "DB1",
|
||||
"node_key": "NODO",
|
||||
"target": {
|
||||
"id": 7,
|
||||
"name": "Omega",
|
||||
"ssh_host": "h",
|
||||
"ssh_username": "u",
|
||||
"ssh_password": "p",
|
||||
"input_folder": "D:\\In",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_config(tmp_path):
|
||||
return {
|
||||
"paths": {
|
||||
"input_folder": str(tmp_path / "in"),
|
||||
"processed_folder": str(tmp_path / "processed"),
|
||||
"failed_folder": str(tmp_path / "failed"),
|
||||
"extract_folder": str(tmp_path / "extract"),
|
||||
"data_sql_folder": str(tmp_path / "data"),
|
||||
"seven_zip_exe": "C:\\Program Files\\7-Zip\\7z.exe",
|
||||
},
|
||||
"sql": {"server": "localhost", "use_windows_auth": True},
|
||||
"timeouts": {"extract_minutes": 30, "restore_minutes": 60},
|
||||
"panel": {
|
||||
"api_url": "http://panel:3000",
|
||||
"api_token": "tok",
|
||||
"instance_key": "Alfa",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FakeJob:
|
||||
source_name = "NODO.ZIP"
|
||||
source_path = "C:\\in\\NODO.ZIP"
|
||||
db_name = "DB1"
|
||||
node_name = "NODO"
|
||||
|
||||
|
||||
def _wire_common(monkeypatch, statuses, reported):
|
||||
"""Mockea las dependencias del worker comunes a los dos escenarios."""
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.get", lambda job_id: FakeJob()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.update_node_and_db",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.update_timing", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.update_status",
|
||||
lambda job_id, status, **k: statuses.append(status),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.delete", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobStepRepository.create", lambda *a, **k: 1
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobStepRepository.complete", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.EventRepository.create", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.panel_client.resolve_route",
|
||||
lambda *a, **k: FORWARD_ROUTE,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.panel_client.report_job_result",
|
||||
lambda **k: reported.append(k.get("status")),
|
||||
)
|
||||
|
||||
|
||||
def test_error_post_entrega_no_degrada_a_fallido(monkeypatch, base_config):
|
||||
"""Entrega OK + error al mover a Procesados => job forwarded, nunca failed."""
|
||||
statuses: list[str] = []
|
||||
reported: list[str] = []
|
||||
_wire_common(monkeypatch, statuses, reported)
|
||||
|
||||
# El SFTP entrega con éxito.
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.sftp_copy.upload_zip_parts",
|
||||
lambda *a, **k: ["D:/In/NODO.ZIP"],
|
||||
)
|
||||
|
||||
moved_to_failed = {"called": False}
|
||||
monkeypatch.setattr(
|
||||
RestoreWorker,
|
||||
"_move_zip_to_failed",
|
||||
lambda self, job: moved_to_failed.__setitem__("called", True),
|
||||
)
|
||||
|
||||
# Error POSTERIOR a la entrega: mover a Procesados falla.
|
||||
def boom(self, job):
|
||||
raise OSError("disco lleno al mover a Procesados")
|
||||
|
||||
monkeypatch.setattr(RestoreWorker, "_move_zip_to_processed", boom)
|
||||
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
worker.run()
|
||||
|
||||
assert JobStatus.COMPLETED in statuses
|
||||
assert JobStatus.FAILED not in statuses
|
||||
assert reported == ["forwarded"]
|
||||
assert moved_to_failed["called"] is False
|
||||
|
||||
|
||||
def test_fallo_real_de_sftp_va_a_fallados_y_limpia_parcial(monkeypatch, base_config):
|
||||
"""Fallo genuino de entrega => FAILED, ZIP a Fallados, y limpieza de partes subidas."""
|
||||
from app.transfer.sftp_copy import SFTPCopyError
|
||||
|
||||
statuses: list[str] = []
|
||||
reported: list[str] = []
|
||||
_wire_common(monkeypatch, statuses, reported)
|
||||
|
||||
# El SFTP falla en la parte 2, adjuntando lo ya subido (envío parcial).
|
||||
err = SFTPCopyError("timeout en la parte 2")
|
||||
err.uploaded = ["D:/In/NODO.ZIP.001"]
|
||||
|
||||
def failing_upload(*a, **k):
|
||||
raise err
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.sftp_copy.upload_zip_parts", failing_upload
|
||||
)
|
||||
|
||||
cleaned: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.sftp_copy.cleanup_remote",
|
||||
lambda target, path: cleaned.append(path),
|
||||
)
|
||||
|
||||
moved_to_failed = {"called": False}
|
||||
monkeypatch.setattr(
|
||||
RestoreWorker,
|
||||
"_move_zip_to_failed",
|
||||
lambda self, job: moved_to_failed.__setitem__("called", True),
|
||||
)
|
||||
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
worker.run()
|
||||
|
||||
assert JobStatus.FAILED in statuses
|
||||
assert JobStatus.COMPLETED not in statuses
|
||||
assert reported == ["failed"]
|
||||
assert moved_to_failed["called"] is True
|
||||
assert cleaned == ["D:/In/NODO.ZIP.001"]
|
||||
89
tests/test_maintenance_scheduler.py
Normal file
89
tests/test_maintenance_scheduler.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Pruebas del programador de mantenimiento diario: gating "máximo 1/día", catch-up tras
|
||||
reinicios y respeto de run_at_hour. Se mockea ConfigRepository con un dict en memoria.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import maintenance_scheduler as msched
|
||||
from app.engine.maintenance_scheduler import DailyMaintenanceScheduler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_store(monkeypatch):
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(
|
||||
msched.ConfigRepository, "get", staticmethod(lambda key, default=None: store.get(key, default))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
msched.ConfigRepository, "set", staticmethod(lambda key, value: store.__setitem__(key, value))
|
||||
)
|
||||
monkeypatch.setattr(msched.EventRepository, "create", staticmethod(lambda *a, **k: None))
|
||||
return store
|
||||
|
||||
|
||||
def _scheduler(runs, clock, run_at_hour=3):
|
||||
return DailyMaintenanceScheduler(
|
||||
task=lambda: runs.append(1),
|
||||
run_at_hour=run_at_hour,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def test_corre_una_vez_por_dia(state_store):
|
||||
runs: list[int] = []
|
||||
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
|
||||
sched = _scheduler(runs, lambda: now["dt"])
|
||||
|
||||
sched._run_if_due() # primer día: corre
|
||||
sched._run_if_due() # mismo día: NO corre
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
def test_catch_up_al_cambiar_de_dia(state_store):
|
||||
runs: list[int] = []
|
||||
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
|
||||
sched = _scheduler(runs, lambda: now["dt"])
|
||||
|
||||
sched._run_if_due()
|
||||
assert len(runs) == 1
|
||||
|
||||
now["dt"] = datetime(2026, 7, 25, 5, 0, 0) # día nuevo
|
||||
sched._run_if_due()
|
||||
assert len(runs) == 2
|
||||
|
||||
|
||||
def test_respeta_run_at_hour(state_store):
|
||||
runs: list[int] = []
|
||||
now = {"dt": datetime(2026, 7, 24, 1, 0, 0)} # antes de las 3
|
||||
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=3)
|
||||
|
||||
sched._run_if_due() # aún no es la hora
|
||||
assert len(runs) == 0
|
||||
|
||||
now["dt"] = datetime(2026, 7, 24, 3, 30, 0) # ya pasó la hora
|
||||
sched._run_if_due()
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
def test_run_at_hour_none_corre_al_primer_wake(state_store):
|
||||
runs: list[int] = []
|
||||
now = {"dt": datetime(2026, 7, 24, 0, 5, 0)}
|
||||
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=None)
|
||||
sched._run_if_due()
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
def test_claim_al_inicio_persiste_fecha_aunque_falle(state_store):
|
||||
"""Si la tarea falla, el turno del día igual se consume (claim al inicio)."""
|
||||
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("fallo de limpieza")
|
||||
|
||||
sched = DailyMaintenanceScheduler(task=boom, run_at_hour=3, clock=lambda: now["dt"])
|
||||
sched._run_if_due() # no debe propagar la excepción
|
||||
|
||||
assert state_store["retention_last_run"]["last_run_date"] == "2026-07-24"
|
||||
assert state_store["retention_last_run"]["last_status"].startswith("error")
|
||||
116
tests/test_multipart_case.py
Normal file
116
tests/test_multipart_case.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Recolección de partes de un ZIP multipart, sin importar la caja de la extensión.
|
||||
|
||||
En este dominio los respaldos llegan con extensión en MAYÚSCULAS con frecuencia (los propios
|
||||
tests del panel usan `GENERICA-TEST.ZIP`). El bug que esto fija: `_collect_zip_paths` hacía
|
||||
`path.stem.split(".zip")[0]`, que con `EMPRESA.ZIP.001` dejaba `base_name="EMPRESA.ZIP"` y
|
||||
armaba el glob `EMPRESA.ZIP.zip.*`, que no encuentra nada. Devolvía lista vacía, y como
|
||||
`_move_zip_to_processed` y `_move_zip_to_failed` iteran sobre ese resultado, **las partes nunca
|
||||
salían de Entrada**: se acumulaban ahí mezcladas con los pendientes.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeExtractor:
|
||||
"""Espeja is_multipart de SevenZipExtractor: reconoce .zip.NNN sin importar la caja."""
|
||||
|
||||
@staticmethod
|
||||
def is_multipart(zip_path: str) -> bool:
|
||||
path = Path(zip_path)
|
||||
# .zip.001 -> suffix ".001", stem "algo.zip"
|
||||
return path.stem.lower().endswith(".zip") and len(path.suffix) == 4
|
||||
|
||||
|
||||
def _collect(source_path: str) -> list[str]:
|
||||
"""
|
||||
Copia de la lógica de RestoreWorker._collect_zip_paths, aislada para poder probarla sin
|
||||
arrastrar PySide6 ni pyodbc. Si la implementación cambia, este test debe cambiar con ella.
|
||||
"""
|
||||
path = Path(source_path)
|
||||
if not _FakeExtractor.is_multipart(str(path)):
|
||||
return [str(path)]
|
||||
|
||||
stem = path.stem
|
||||
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
|
||||
prefix = f"{base_name}.zip.".lower()
|
||||
|
||||
parts = sorted(
|
||||
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
|
||||
key=lambda item: item.name.lower(),
|
||||
)
|
||||
if parts:
|
||||
return [str(item) for item in parts]
|
||||
return [str(path)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", ["zip", "ZIP", "Zip"])
|
||||
def test_recolecta_todas_las_partes_sin_importar_la_caja(tmp_path: Path, ext: str):
|
||||
"""Las tres partes se recolectan igual con la extensión en minúsculas, MAYÚSCULAS o mixta."""
|
||||
names = [f"EMPRESA.{ext}.001", f"EMPRESA.{ext}.002", f"EMPRESA.{ext}.003"]
|
||||
for name in names:
|
||||
(tmp_path / name).write_bytes(b"x")
|
||||
|
||||
collected = _collect(str(tmp_path / names[0]))
|
||||
|
||||
assert [Path(p).name for p in collected] == names, (
|
||||
f"con extensión .{ext} se recolectaron {len(collected)} de {len(names)} partes"
|
||||
)
|
||||
|
||||
|
||||
def test_orden_estable_entre_partes(tmp_path: Path):
|
||||
"""El orden importa: 7-Zip necesita la .001 primero para reensamblar."""
|
||||
for i in (3, 1, 10, 2):
|
||||
(tmp_path / f"BASE.ZIP.{i:03d}").write_bytes(b"x")
|
||||
|
||||
collected = [Path(p).name for p in _collect(str(tmp_path / "BASE.ZIP.001"))]
|
||||
assert collected == ["BASE.ZIP.001", "BASE.ZIP.002", "BASE.ZIP.003", "BASE.ZIP.010"]
|
||||
|
||||
|
||||
def test_no_mezcla_partes_de_otro_respaldo(tmp_path: Path):
|
||||
"""Dos multipart en la misma carpeta no deben contaminarse entre sí."""
|
||||
for name in ["ALFA.ZIP.001", "ALFA.ZIP.002", "OMEGA.ZIP.001", "OMEGA.zip.002"]:
|
||||
(tmp_path / name).write_bytes(b"x")
|
||||
|
||||
alfa = [Path(p).name for p in _collect(str(tmp_path / "ALFA.ZIP.001"))]
|
||||
assert alfa == ["ALFA.ZIP.001", "ALFA.ZIP.002"]
|
||||
|
||||
# OMEGA tiene sus dos partes con distinta caja: aun así deben salir las dos.
|
||||
omega = [Path(p).name for p in _collect(str(tmp_path / "OMEGA.ZIP.001"))]
|
||||
assert sorted(omega) == ["OMEGA.ZIP.001", "OMEGA.zip.002"]
|
||||
|
||||
|
||||
def test_zip_simple_devuelve_solo_ese_archivo(tmp_path: Path):
|
||||
simple = tmp_path / "UNICO.ZIP"
|
||||
simple.write_bytes(b"x")
|
||||
assert _collect(str(simple)) == [str(simple)]
|
||||
|
||||
|
||||
def test_multipart_sin_partes_localizadas_devuelve_el_original(tmp_path: Path):
|
||||
"""
|
||||
Si no se localizan las partes, se devuelve el archivo original en lugar de lista vacía:
|
||||
mover una sola parte es mejor que dejarla atorada en Entrada indefinidamente.
|
||||
"""
|
||||
huerfana = tmp_path / "SOLA.ZIP.007"
|
||||
huerfana.write_bytes(b"x")
|
||||
# Es multipart por el nombre, y su propia parte sí se encuentra.
|
||||
assert _collect(str(huerfana)) == [str(huerfana)]
|
||||
|
||||
|
||||
def test_la_implementacion_real_no_usa_glob_en_minusculas():
|
||||
"""
|
||||
Tripwire sobre el código real: `glob` distingue mayúsculas en Linux, así que un patrón
|
||||
en minúsculas nunca encontraría `.ZIP.001`. La implementación debe filtrar iterdir()
|
||||
comparando en minúsculas.
|
||||
"""
|
||||
source = (
|
||||
Path(__file__).resolve().parent.parent / "app" / "engine" / "restore_worker.py"
|
||||
).read_text(encoding="utf-8")
|
||||
start = source.index("def _collect_zip_paths")
|
||||
body = source[start : start + 2000]
|
||||
|
||||
assert 'glob(f"{base_name}.zip.*")' not in body, "glob en minúsculas: no halla .ZIP en Linux"
|
||||
assert "iterdir()" in body
|
||||
assert ".lower()" in body
|
||||
@@ -57,7 +57,9 @@ def test_target_db_vacio_devuelve_none():
|
||||
def test_target_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
# **kwargs porque panel_client también pasa verify=; una firma rígida rompe la prueba
|
||||
# cada vez que se agrega un kwarg al cliente.
|
||||
def fake_get(url, headers=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, VALID_TARGET)
|
||||
|
||||
@@ -116,7 +118,7 @@ def test_target_json_invalido_devuelve_none(monkeypatch):
|
||||
def test_report_job_result_201_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
|
||||
captured["json"] = json
|
||||
return FakeResponse(201)
|
||||
|
||||
@@ -166,7 +168,7 @@ def test_test_connection_url_invalida():
|
||||
def test_report_instance_config_200_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
return FakeResponse(200)
|
||||
@@ -256,7 +258,7 @@ CATALOG_RESPONSE = {
|
||||
def test_list_restore_target_names_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
def fake_get(url, headers=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, CATALOG_RESPONSE)
|
||||
|
||||
@@ -346,7 +348,7 @@ ROUTE_RESTORE_LOCAL = {
|
||||
def test_resolve_route_forward_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
def fake_get(url, headers=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, ROUTE_FORWARD)
|
||||
|
||||
@@ -379,3 +381,71 @@ def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
|
||||
)
|
||||
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# platform / arch: el PANEL los usa para elegir qué artefacto le toca a este
|
||||
# servidor al instalar o actualizar (a24c.cras_releases se llavea por
|
||||
# version + platform + arch).
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _capture_post(monkeypatch) -> dict:
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
captured["headers"] = headers
|
||||
return FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
def test_report_instance_config_envia_platform_y_arch(monkeypatch):
|
||||
captured = _capture_post(monkeypatch)
|
||||
ok = panel_client.report_instance_config(
|
||||
URL,
|
||||
TOKEN,
|
||||
r"D:\Backups\Entrada",
|
||||
processed_folder=r"D:\Backups\Procesados",
|
||||
host_name="WIN-01",
|
||||
app_version="1.1.0",
|
||||
instance_key="Alfa",
|
||||
platform_name="windows",
|
||||
arch="x86_64",
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["json"]["platform"] == "windows"
|
||||
assert captured["json"]["arch"] == "x86_64"
|
||||
assert captured["json"]["processed_folder"] == r"D:\Backups\Procesados"
|
||||
|
||||
|
||||
def test_report_instance_config_sin_platform_manda_none(monkeypatch):
|
||||
# Compatibilidad hacia atrás: un agente viejo no manda estas claves y el PANEL
|
||||
# debe poder caer al texto libre de restore_targets.os.
|
||||
captured = _capture_post(monkeypatch)
|
||||
panel_client.report_instance_config(URL, TOKEN, r"D:\In")
|
||||
assert captured["json"]["platform"] is None
|
||||
assert captured["json"]["arch"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", None])
|
||||
def test_report_instance_config_platform_en_blanco_es_none(monkeypatch, blank):
|
||||
captured = _capture_post(monkeypatch)
|
||||
panel_client.report_instance_config(
|
||||
URL, TOKEN, r"D:\In", platform_name=blank, arch=blank
|
||||
)
|
||||
assert captured["json"]["platform"] is None
|
||||
assert captured["json"]["arch"] is None
|
||||
|
||||
|
||||
def test_constantes_platform_arch_son_del_vocabulario_del_panel():
|
||||
# El PANEL valida platform contra ('windows','linux'); si esto cambia hay que
|
||||
# actualizar el CHECK de a24c.cras_releases y la validación del endpoint.
|
||||
from app.constants import APP_ARCH, APP_PLATFORM
|
||||
|
||||
assert APP_PLATFORM in ("windows", "linux")
|
||||
assert APP_ARCH and APP_ARCH == APP_ARCH.strip()
|
||||
assert " " not in APP_ARCH
|
||||
|
||||
291
tests/test_release_metadata.py
Normal file
291
tests/test_release_metadata.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Pruebas del contrato de release: versión, artefactos y manifiesto.
|
||||
|
||||
Lo que se protege aquí es la cadena que hace posible la distribución automatizada:
|
||||
app/__init__.py es la fuente única de la versión, package-release.sh la usa para nombrar
|
||||
los artefactos y armar release.json, y el PANEL compara versiones como tuplas de enteros
|
||||
para saber si hay una más nueva. Un formato de versión distinto rompe esa comparación en
|
||||
silencio, así que se valida el formato, no solo que exista.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Mismo patrón que valida package-release.sh y que el PANEL puede ordenar.
|
||||
VERSION_RE = re.compile(r"^\d+(\.\d+){1,3}$")
|
||||
|
||||
|
||||
def read_version_from_source() -> str:
|
||||
"""Lee __version__ del archivo, sin importar el paquete (igual que el spec)."""
|
||||
text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8")
|
||||
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
|
||||
assert match, "no se encontró __version__ en app/__init__.py"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_version_tiene_formato_comparable():
|
||||
assert VERSION_RE.match(read_version_from_source())
|
||||
|
||||
|
||||
def test_version_del_paquete_coincide_con_el_archivo():
|
||||
# El spec de PyInstaller y package-release.sh leen el archivo con regex; el resto de
|
||||
# la app importa app.__version__. Ambos caminos deben dar lo mismo.
|
||||
from app import __version__
|
||||
|
||||
assert __version__ == read_version_from_source()
|
||||
|
||||
|
||||
def test_ui_no_hardcodea_la_version():
|
||||
# El diálogo "Acerca de" traía la versión literal y quedó desfasado del paquete.
|
||||
text = (ROOT / "app" / "ui" / "main_window.py").read_text(encoding="utf-8")
|
||||
assert "__version__" in text
|
||||
assert not re.search(r"CloudRestoreAS v\d+\.\d+\.\d+", text)
|
||||
|
||||
|
||||
def test_bundled_versions_es_json_valido():
|
||||
# El bootstrap calcula el sha256 de este archivo para decidir si re-despliega
|
||||
# config/7zip y config/odbc; si no es JSON válido el build queda inconsistente.
|
||||
manifest = ROOT / "packaging" / "bundled-versions.json"
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
assert data.get("seven_zip"), "falta la versión de 7-Zip"
|
||||
|
||||
|
||||
def test_spec_inyecta_metadatos_de_version_en_windows():
|
||||
spec = (ROOT / "packaging" / "CloudRestoreAS.spec").read_text(encoding="utf-8")
|
||||
assert "version=_version_info" in spec, "el EXE debe recibir el recurso de versión"
|
||||
assert "bundled-versions.json" in spec, "bundled-versions.json debe ir embebido"
|
||||
|
||||
|
||||
def test_instalador_de_despliegue_y_script_de_desarrollo_estan_separados():
|
||||
"""
|
||||
scripts/dev-setup.ps1 prepara un venv de desarrollo y no tiene nada que hacer en el
|
||||
zip del ejecutable autocontenido; install.ps1 es el instalador de despliegue. Antes
|
||||
eran el mismo archivo y se empaquetaba el de desarrollo.
|
||||
"""
|
||||
script = (ROOT / "packaging" / "scripts" / "package-release.sh").read_text(encoding="utf-8")
|
||||
assert '(root / "install.ps1", "CloudRestoreAS/install.ps1")' in script
|
||||
assert "CloudRestoreAS/dev-setup.ps1" not in script
|
||||
|
||||
dev_setup = ROOT / "scripts" / "dev-setup.ps1"
|
||||
installer = ROOT / "install.ps1"
|
||||
assert dev_setup.is_file() and installer.is_file()
|
||||
# El de desarrollo crea venv; el de despliegue registra el arranque automático.
|
||||
assert "venv" in dev_setup.read_text(encoding="utf-8")
|
||||
assert "Register-ScheduledTask" in installer.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_instaladores_aceptan_panel_env_file():
|
||||
"""
|
||||
El instalador remoto del PANEL siembra las credenciales por archivo, no por argv,
|
||||
para que el token no quede visible en `ps` ni en el historial del destino.
|
||||
"""
|
||||
sh = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
|
||||
assert "--panel-env-file" in sh
|
||||
assert "PanelEnvFile" in ps1
|
||||
# Lista blanca de claves en ambos: el archivo llega por la red.
|
||||
for text in (sh, ps1):
|
||||
assert "CLOUDRESTORE_PANEL_API_TOKEN" in text
|
||||
assert "CLOUDRESTORE_PANEL_INSTANCE_KEY" in text
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash y sha256sum")
|
||||
def test_package_release_genera_manifiesto_consistente(tmp_path: Path):
|
||||
"""
|
||||
Corre package-release.sh contra un árbol mínimo con binarios simulados y verifica
|
||||
que release.json y SHA256SUMS concuerden entre sí y con los archivos en disco.
|
||||
"""
|
||||
for rel in (
|
||||
"app/__init__.py",
|
||||
"packaging/scripts/package-release.sh",
|
||||
"packaging/LEEME.txt",
|
||||
"packaging/bundled-versions.json",
|
||||
"packaging/linux/cloudrestoreas.service",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
):
|
||||
dest = tmp_path / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes((ROOT / rel).read_bytes())
|
||||
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / "CloudRestoreAS").write_text("ELF simulado\n", encoding="utf-8")
|
||||
(dist / "CloudRestoreAS.exe").write_text("PE simulado\n", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Los binarios simulados son de unos bytes; se desactiva el piso de tamaño, que se
|
||||
# prueba aparte en test_package_release_rechaza_binario_truncado.
|
||||
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": "0"},
|
||||
)
|
||||
assert result.returncode == 0, f"package-release.sh falló:\n{result.stderr}"
|
||||
|
||||
release_dir = dist / "release"
|
||||
manifest = json.loads((release_dir / "release.json").read_text(encoding="utf-8"))
|
||||
version = read_version_from_source()
|
||||
|
||||
assert manifest["version"] == version
|
||||
assert manifest["product"] == "CloudRestoreAS"
|
||||
assert manifest["bundled"]["seven_zip"], "el manifiesto debe registrar las deps embebidas"
|
||||
|
||||
platforms = {a["platform"] for a in manifest["artifacts"]}
|
||||
assert platforms == {"linux", "windows"}
|
||||
|
||||
# Cada artefacto: existe, el nombre lleva versión y plataforma, y el sha256/tamaño
|
||||
# del manifiesto coinciden con el archivo real.
|
||||
import hashlib
|
||||
|
||||
sums = dict(
|
||||
reversed(line.split(maxsplit=1))
|
||||
for line in (release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
for artifact in manifest["artifacts"]:
|
||||
path = release_dir / artifact["file_name"]
|
||||
assert path.is_file(), f"falta el artefacto {artifact['file_name']}"
|
||||
assert version in artifact["file_name"]
|
||||
assert artifact["arch"] in artifact["file_name"]
|
||||
assert path.stat().st_size == artifact["size"]
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"]
|
||||
# SHA256SUMS y release.json no deben poder divergir.
|
||||
assert sums[path.name.strip()].strip() == artifact["sha256"]
|
||||
|
||||
# Contenido de los paquetes: el instalador de despliegue va dentro, el script de
|
||||
# desarrollo NO (antes se empaquetaba dev-setup.ps1 junto al .exe autocontenido).
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
win_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "windows")
|
||||
with zipfile.ZipFile(release_dir / win_pkg["file_name"]) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "CloudRestoreAS/CloudRestoreAS.exe" in names
|
||||
assert "CloudRestoreAS/install.ps1" in names
|
||||
assert not any("dev-setup" in n for n in names)
|
||||
|
||||
linux_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "linux")
|
||||
with tarfile.open(release_dir / linux_pkg["file_name"]) as tf:
|
||||
members = set(tf.getnames())
|
||||
# El instalador remoto extrae y corre CloudRestoreAS/install.sh, que a su vez busca
|
||||
# el binario y la unit systemd relativos a su ubicación.
|
||||
assert "CloudRestoreAS/CloudRestoreAS-linux" in members
|
||||
assert "CloudRestoreAS/install.sh" in members
|
||||
assert "CloudRestoreAS/packaging/linux/cloudrestoreas.service" in members
|
||||
|
||||
|
||||
def _stage_package_tree(tmp_path: Path) -> Path:
|
||||
"""Árbol mínimo para correr package-release.sh. Devuelve dist/."""
|
||||
for rel in (
|
||||
"app/__init__.py",
|
||||
"packaging/scripts/package-release.sh",
|
||||
"packaging/LEEME.txt",
|
||||
"packaging/bundled-versions.json",
|
||||
"packaging/linux/cloudrestoreas.service",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
):
|
||||
dest = tmp_path / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes((ROOT / rel).read_bytes())
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "release").mkdir(parents=True, exist_ok=True)
|
||||
return dist
|
||||
|
||||
|
||||
def _run_package(tmp_path: Path, min_mb: str = "1") -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": min_mb},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
|
||||
def test_package_release_ignora_binario_rancio_en_release(tmp_path: Path):
|
||||
"""
|
||||
Un binario que quedó en dist/release/ de una corrida anterior NO debe empaquetarse.
|
||||
|
||||
Así se generó una vez un zip etiquetado 1.1.0 con un .exe parcial de 4.9 MB: el build de
|
||||
Windows había fallado, pero el empaquetado tomaba la copia vieja de dist/release/ en lugar
|
||||
del binario recién compilado en dist/. El sha256 y el release.json quedaban consistentes
|
||||
con los bytes equivocados, así que la verificación de integridad no lo detectaba.
|
||||
"""
|
||||
dist = _stage_package_tree(tmp_path)
|
||||
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) # Linux sí compiló
|
||||
# Sobrante de una corrida previa; dist/CloudRestoreAS.exe NO existe.
|
||||
(dist / "release" / "CloudRestoreAS.exe").write_bytes(b"parcial" * 1000)
|
||||
|
||||
result = _run_package(tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
manifest = json.loads((dist / "release" / "release.json").read_text(encoding="utf-8"))
|
||||
assert [a["platform"] for a in manifest["artifacts"]] == ["linux"]
|
||||
assert not list((dist / "release").glob("*win*.zip")), "no debió empaquetar Windows"
|
||||
# La copia rancia se elimina para que no reaparezca en la siguiente corrida.
|
||||
assert not (dist / "release" / "CloudRestoreAS.exe").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
|
||||
def test_package_release_rechaza_binario_truncado(tmp_path: Path):
|
||||
"""Un build a medias debe abortar el empaquetado completo, no publicarse."""
|
||||
dist = _stage_package_tree(tmp_path)
|
||||
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
|
||||
(dist / "CloudRestoreAS.exe").write_bytes(b"x" * 1024) # muy por debajo del piso
|
||||
|
||||
result = _run_package(tmp_path, min_mb="1")
|
||||
assert result.returncode != 0
|
||||
salida = result.stdout + result.stderr
|
||||
assert "truncado" in salida
|
||||
assert "abortado" in salida
|
||||
# Nada debe quedar publicable si alguna plataforma es inválida.
|
||||
assert not (dist / "release" / "release.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
|
||||
def test_package_release_rechaza_binario_anterior_al_bump(tmp_path: Path):
|
||||
"""
|
||||
Un binario más viejo que app/__init__.py pertenece a otra versión. Empaquetarlo con el
|
||||
nombre de la versión actual publicaría una mentira que el sha256 no puede delatar.
|
||||
"""
|
||||
dist = _stage_package_tree(tmp_path)
|
||||
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
|
||||
exe = dist / "CloudRestoreAS.exe"
|
||||
exe.write_bytes(b"x" * (2 * 1024 * 1024))
|
||||
os.utime(exe, (0, 0)) # 1970: anterior a cualquier cambio de versión
|
||||
|
||||
result = _run_package(tmp_path)
|
||||
assert result.returncode != 0
|
||||
salida = result.stdout + result.stderr
|
||||
assert "MÁS VIEJO" in salida
|
||||
assert not (dist / "release" / "release.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
|
||||
def test_package_release_rechaza_version_invalida(tmp_path: Path):
|
||||
"""Una versión no comparable debe abortar el empaquetado, no publicarse."""
|
||||
for rel in ("packaging/scripts/package-release.sh", "packaging/LEEME.txt"):
|
||||
dest = tmp_path / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes((ROOT / rel).read_bytes())
|
||||
(tmp_path / "app").mkdir()
|
||||
(tmp_path / "app" / "__init__.py").write_text('__version__ = "1.0.0-rc1"\n', encoding="utf-8")
|
||||
(tmp_path / "dist").mkdir()
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "inválida" in (result.stdout + result.stderr)
|
||||
157
tests/test_retention.py
Normal file
157
tests/test_retention.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Pruebas de la retención por nodo (Procesados/) y por antigüedad (Fallados/).
|
||||
|
||||
Se mockea el acceso a la BD (JobRepository/EventRepository) y se opera sobre carpetas reales
|
||||
en tmp_path. Las fechas-carpeta de Procesados/ se derivan con el mismo helper que usa el
|
||||
limpiador para que la prueba sea independiente de la zona horaria del runner.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.retention import RetentionCleaner
|
||||
|
||||
|
||||
class FakeJob:
|
||||
def __init__(self, job_id, node_name, source_name, finished_at):
|
||||
self.job_id = job_id
|
||||
self.node_name = node_name
|
||||
self.source_name = source_name
|
||||
self.finished_at = finished_at
|
||||
|
||||
|
||||
def _config(tmp_path, *, days=2, failed_days=7, dry_run=False):
|
||||
return {
|
||||
"paths": {
|
||||
"processed_folder": str(tmp_path / "processed"),
|
||||
"failed_folder": str(tmp_path / "failed"),
|
||||
},
|
||||
"retention": {"days": days, "failed_days": failed_days, "dry_run": dry_run},
|
||||
}
|
||||
|
||||
|
||||
def _date_folder(base, iso):
|
||||
"""Crea (si falta) la carpeta-fecha local correspondiente a un finished_at ISO UTC."""
|
||||
local_date = RetentionCleaner._local_date_from_iso(iso)
|
||||
folder = base / local_date.isoformat()
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def test_procesados_borra_obsoletos_conserva_reciente_y_nodo_unico(monkeypatch, tmp_path):
|
||||
processed = tmp_path / "processed"
|
||||
ref_iso = "2026-07-24T10:00:00"
|
||||
obsolete_iso = "2026-07-20T10:00:00"
|
||||
node_b_iso = "2026-07-22T10:00:00"
|
||||
|
||||
# NODO_A: copia obsoleta (a borrar) + copia más reciente (a conservar).
|
||||
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
|
||||
obsolete_file.write_bytes(b"viejo")
|
||||
recent_file = _date_folder(processed, ref_iso) / "NODO_A.zip"
|
||||
recent_file.write_bytes(b"nuevo")
|
||||
# NODO_B: una sola restauración (nunca se toca).
|
||||
node_b_file = _date_folder(processed, node_b_iso) / "NODO_B.zip"
|
||||
node_b_file.write_bytes(b"unico")
|
||||
|
||||
obsolete_job = FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)
|
||||
|
||||
purged: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
|
||||
lambda days: [obsolete_job],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_latest_completed_per_node",
|
||||
lambda: {"NODO_A": ref_iso, "NODO_B": node_b_iso},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
|
||||
)
|
||||
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
|
||||
|
||||
result = RetentionCleaner(_config(tmp_path)).run()
|
||||
|
||||
assert not obsolete_file.exists() # obsoleto borrado
|
||||
assert recent_file.exists() # más reciente intacto
|
||||
assert node_b_file.exists() # nodo de una sola copia intacto
|
||||
assert purged == ["job-a-old"]
|
||||
assert result.deleted_files == 1
|
||||
|
||||
|
||||
def test_procesados_dry_run_no_borra(monkeypatch, tmp_path):
|
||||
processed = tmp_path / "processed"
|
||||
obsolete_iso = "2026-07-20T10:00:00"
|
||||
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
|
||||
obsolete_file.write_bytes(b"viejo")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
|
||||
lambda days: [FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_latest_completed_per_node",
|
||||
lambda: {"NODO_A": "2026-07-24T10:00:00"},
|
||||
)
|
||||
purged: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
|
||||
)
|
||||
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
|
||||
|
||||
result = RetentionCleaner(_config(tmp_path, dry_run=True)).run()
|
||||
|
||||
assert obsolete_file.exists() # dry-run no borra
|
||||
assert purged == [] # ni marca purgado
|
||||
assert result.dry_run is True
|
||||
assert result.deleted_files == 1 # sí lo contabiliza como "se borraría"
|
||||
|
||||
|
||||
def test_fallados_borra_por_antiguedad_absoluta(monkeypatch, tmp_path):
|
||||
failed = tmp_path / "failed"
|
||||
old_folder = failed / "2026-07-10" # < (hoy - 7)
|
||||
recent_folder = failed / "2026-07-20" # >= (hoy - 7)
|
||||
old_folder.mkdir(parents=True)
|
||||
recent_folder.mkdir(parents=True)
|
||||
old_file = old_folder / "VIEJO.zip"
|
||||
old_file.write_bytes(b"x")
|
||||
recent_file = recent_folder / "RECIENTE.zip"
|
||||
recent_file.write_bytes(b"y")
|
||||
|
||||
# Sin obsoletos en Procesados; solo probamos Fallados.
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
|
||||
)
|
||||
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
|
||||
|
||||
cleaner = RetentionCleaner(
|
||||
_config(tmp_path, failed_days=7), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
|
||||
)
|
||||
cleaner.run()
|
||||
|
||||
assert not old_file.exists() # carpeta-fecha vieja borrada
|
||||
assert recent_file.exists() # dentro de la ventana, se conserva
|
||||
|
||||
|
||||
def test_fallados_ignora_carpetas_no_fecha(monkeypatch, tmp_path):
|
||||
failed = tmp_path / "failed"
|
||||
weird = failed / "no-es-fecha"
|
||||
weird.mkdir(parents=True)
|
||||
keep = weird / "algo.zip"
|
||||
keep.write_bytes(b"z")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
|
||||
)
|
||||
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
|
||||
|
||||
RetentionCleaner(
|
||||
_config(tmp_path), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
|
||||
).run()
|
||||
|
||||
assert keep.exists() # nombre que no es fecha: no se toca
|
||||
@@ -2,6 +2,8 @@
|
||||
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
|
||||
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.transfer import sftp_copy
|
||||
@@ -32,12 +34,26 @@ def test_upload_origen_inexistente(tmp_path):
|
||||
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
|
||||
|
||||
|
||||
class FakeStat:
|
||||
def __init__(self, st_size):
|
||||
self.st_size = st_size
|
||||
|
||||
|
||||
class FakeSFTP:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def put(self, local, remote):
|
||||
def put(self, local, remote, confirm=True):
|
||||
self.store["put"] = (local, remote)
|
||||
self.store["confirm"] = confirm
|
||||
# Registra el tamaño para que stat() (verificación de subida) lo confirme.
|
||||
self.store.setdefault("sizes", {})[remote] = os.path.getsize(local)
|
||||
|
||||
def stat(self, remote):
|
||||
sizes = self.store.get("sizes", {})
|
||||
if remote not in sizes:
|
||||
raise FileNotFoundError(remote)
|
||||
return FakeStat(sizes[remote])
|
||||
|
||||
def remove(self, remote):
|
||||
self.store["removed"] = remote
|
||||
@@ -133,3 +149,65 @@ def test_upload_file_to_folder_vacio_falla(tmp_path):
|
||||
f.write_bytes(b"x")
|
||||
with pytest.raises(SFTPCopyError, match="carpeta remota"):
|
||||
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
|
||||
|
||||
|
||||
def test_upload_usa_confirm_false(tmp_path, monkeypatch):
|
||||
"""La subida no debe delegar la verificación al confirm inmediato de paramiko."""
|
||||
zf = tmp_path / "backup.zip"
|
||||
zf.write_bytes(b"zipdata")
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
|
||||
|
||||
sftp_copy.upload_file_to_folder(str(zf), CFG, "D:\\In")
|
||||
assert store["confirm"] is False
|
||||
|
||||
|
||||
def test_verify_remote_size_reintenta_stat_flaky(monkeypatch):
|
||||
"""Un stat transitoriamente fallido se reintenta y NO produce falso fallo."""
|
||||
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
|
||||
calls = {"n": 0}
|
||||
|
||||
class Flaky:
|
||||
def stat(self, remote):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 2:
|
||||
raise OSError("stat flaky")
|
||||
return FakeStat(100)
|
||||
|
||||
sftp_copy._verify_remote_size(Flaky(), "C:/In/x.zip", 100) # no debe lanzar
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_verify_remote_size_tamano_incorrecto_falla(monkeypatch):
|
||||
"""Si el tamaño remoto nunca coincide, es un fallo genuino de entrega."""
|
||||
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
|
||||
|
||||
class Wrong:
|
||||
def stat(self, remote):
|
||||
return FakeStat(50)
|
||||
|
||||
with pytest.raises(SFTPCopyError, match="verificar"):
|
||||
sftp_copy._verify_remote_size(Wrong(), "C:/In/x.zip", 100)
|
||||
|
||||
|
||||
def test_upload_zip_parts_adjunta_uploaded_en_fallo(tmp_path, monkeypatch):
|
||||
"""Ante un fallo parcial, la excepción lleva las partes ya subidas para limpieza."""
|
||||
p1 = tmp_path / "big.zip.001"
|
||||
p2 = tmp_path / "big.zip.002"
|
||||
p1.write_bytes(b"a")
|
||||
p2.write_bytes(b"b")
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_upload(local, cfg, remote_folder):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return "D:/In/big.zip.001"
|
||||
raise SFTPCopyError("boom en la parte 2")
|
||||
|
||||
monkeypatch.setattr(sftp_copy, "upload_file_to_folder", fake_upload)
|
||||
|
||||
with pytest.raises(SFTPCopyError) as exc_info:
|
||||
sftp_copy.upload_zip_parts([str(p1), str(p2)], CFG, "D:\\In")
|
||||
|
||||
assert getattr(exc_info.value, "uploaded", None) == ["D:/In/big.zip.001"]
|
||||
|
||||
Reference in New Issue
Block a user