171 lines
5.4 KiB
Python
171 lines
5.4 KiB
Python
"""
|
|
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"]
|