feature/integracion-panel-restore-targets
This commit is contained in:
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Configuración de pytest: asegura que el paquete `app` sea importable."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
41
tests/test_colocated_resolve.py
Normal file
41
tests/test_colocated_resolve.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Pruebas de validación modo colocado (target.name vs instance_key).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.panel import panel_client
|
||||
|
||||
|
||||
def test_validate_colocado_acepta_sin_ssh():
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Omega",
|
||||
"server": "192.168.1.10",
|
||||
"username": "sa",
|
||||
"password": "x",
|
||||
"data_folder": "C:\\SQLData",
|
||||
}
|
||||
assert panel_client._validate_target(data, colocated=True) is True
|
||||
|
||||
|
||||
def test_validate_colocado_rechaza_sin_name():
|
||||
data = {
|
||||
"id": 1,
|
||||
"server": "192.168.1.10",
|
||||
"username": "sa",
|
||||
"password": "x",
|
||||
"data_folder": "C:\\SQLData",
|
||||
}
|
||||
assert panel_client._validate_target(data, colocated=True) is False
|
||||
|
||||
|
||||
def test_validate_hub_exige_ssh():
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Omega",
|
||||
"server": "192.168.1.10",
|
||||
"username": "sa",
|
||||
"password": "x",
|
||||
"data_folder": "C:\\SQLData",
|
||||
}
|
||||
assert panel_client._validate_target(data, colocated=False) is False
|
||||
381
tests/test_panel_client.py
Normal file
381
tests/test_panel_client.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Pruebas del cliente del PANEL (servidor de restauración asignado por base de datos).
|
||||
Se mockea `requests` para no depender de un PANEL real.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.panel import panel_client
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, json_data=None, raise_json=False):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
self._raise_json = raise_json
|
||||
|
||||
def json(self):
|
||||
if self._raise_json:
|
||||
raise ValueError("no es JSON")
|
||||
return self._json
|
||||
|
||||
|
||||
VALID_TARGET = {
|
||||
"id": 2,
|
||||
"name": "Omega",
|
||||
"server": "192.168.1.100,1433",
|
||||
"username": "sa",
|
||||
"password": "secreto",
|
||||
"data_folder": "C:\\SQLData",
|
||||
"ssh_host": "192.168.1.100",
|
||||
"ssh_port": 22,
|
||||
"ssh_username": "Administrator",
|
||||
"ssh_password": "ssh-secreto",
|
||||
"remote_inbox_path": "C:\\RestoreInbox",
|
||||
}
|
||||
|
||||
URL = "http://panel:3000"
|
||||
TOKEN = "tok"
|
||||
DB = "EMPRESA_DB"
|
||||
|
||||
|
||||
def test_target_url_vacia_devuelve_none():
|
||||
assert panel_client.get_target_for_database("", TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_target_url_invalida_devuelve_none():
|
||||
assert panel_client.get_target_for_database("ftp://x", TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_target_token_vacio_devuelve_none():
|
||||
assert panel_client.get_target_for_database(URL, "", DB) is None
|
||||
|
||||
|
||||
def test_target_db_vacio_devuelve_none():
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, " ") is None
|
||||
|
||||
|
||||
def test_target_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, VALID_TARGET)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "get", fake_get)
|
||||
target = panel_client.get_target_for_database(URL, TOKEN, DB)
|
||||
assert target is not None
|
||||
assert target["name"] == "Omega"
|
||||
# el db_name viaja como query param url-encoded
|
||||
assert "database=EMPRESA_DB" in captured["url"]
|
||||
|
||||
|
||||
def test_target_db_con_espacios_se_encodea(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get",
|
||||
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
|
||||
)
|
||||
panel_client.get_target_for_database(URL, TOKEN, "MI BASE")
|
||||
assert "MI%20BASE" in captured["url"]
|
||||
|
||||
|
||||
def test_target_falta_campo_requerido(monkeypatch):
|
||||
incompleto = dict(VALID_TARGET)
|
||||
del incompleto["password"]
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, incompleto))
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_target_campo_vacio_es_invalido(monkeypatch):
|
||||
vacio = dict(VALID_TARGET, password=" ")
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, vacio))
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 401, 500, 503])
|
||||
def test_target_codigos_no_200_devuelven_none(monkeypatch, code):
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_target_error_red_devuelve_none(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise panel_client.requests.exceptions.ConnectionError("caído")
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "get", boom)
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_target_json_invalido_devuelve_none(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
|
||||
)
|
||||
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
|
||||
|
||||
|
||||
def test_report_job_result_201_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured["json"] = json
|
||||
return FakeResponse(201)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "post", fake_post)
|
||||
ok = panel_client.report_job_result(
|
||||
URL, TOKEN, filename="empresa.bak", status="completed",
|
||||
restore_target_id=2, db_name="EMP", duration_ms=1000
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["json"]["filename"] == "empresa.bak"
|
||||
assert captured["json"]["status"] == "completed"
|
||||
|
||||
|
||||
def test_report_job_result_otro_codigo_false(monkeypatch):
|
||||
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
|
||||
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
|
||||
|
||||
|
||||
def test_report_job_result_error_red_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise panel_client.requests.exceptions.Timeout("timeout")
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "post", boom)
|
||||
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [200, 404])
|
||||
def test_test_connection_ok(monkeypatch, code):
|
||||
# 200 o 404 → conexión y token correctos
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
|
||||
ok, info = panel_client.test_connection(URL, TOKEN)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_test_connection_token_rechazado(monkeypatch):
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(401))
|
||||
ok, info = panel_client.test_connection(URL, TOKEN)
|
||||
assert ok is False
|
||||
assert "401" in info
|
||||
|
||||
|
||||
def test_test_connection_url_invalida():
|
||||
ok, info = panel_client.test_connection("noesurl", TOKEN)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_report_instance_config_200_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
return FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "post", fake_post)
|
||||
ok = panel_client.report_instance_config(
|
||||
URL, TOKEN, r"D:\Backups\Entrada", host_name="WIN-01", app_version="1.0.0"
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["url"].endswith("/api/restore/instance-config")
|
||||
assert captured["json"]["input_folder"] == r"D:\Backups\Entrada"
|
||||
assert captured["json"]["host_name"] == "WIN-01"
|
||||
|
||||
|
||||
def test_report_instance_config_input_vacio_false():
|
||||
assert panel_client.report_instance_config(URL, TOKEN, " ") is False
|
||||
|
||||
|
||||
def test_report_instance_config_sin_token_false():
|
||||
assert panel_client.report_instance_config(URL, "", r"D:\In") is False
|
||||
|
||||
|
||||
def test_report_instance_config_otro_codigo_false(monkeypatch):
|
||||
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
|
||||
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
|
||||
|
||||
|
||||
def test_report_instance_config_error_red_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise panel_client.requests.exceptions.ConnectionError("caído")
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "post", boom)
|
||||
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
|
||||
|
||||
|
||||
def test_target_con_instance_envia_param(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get",
|
||||
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
|
||||
)
|
||||
panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
|
||||
assert "instance=Alfa" in captured["url"]
|
||||
|
||||
|
||||
COLOCATED_TARGET = {
|
||||
"id": 1,
|
||||
"name": "Alfa",
|
||||
"server": "localhost",
|
||||
"username": "sa",
|
||||
"password": "secreto",
|
||||
"data_folder": "C:\\SQLData",
|
||||
}
|
||||
|
||||
|
||||
def test_target_colocado_no_exige_ssh(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, COLOCATED_TARGET)
|
||||
)
|
||||
target = panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
|
||||
assert target is not None
|
||||
assert target["name"] == "Alfa"
|
||||
|
||||
|
||||
def test_report_instance_config_con_instance_key(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "post",
|
||||
lambda url, json=None, **k: (captured.update({"json": json}), FakeResponse(200))[1]
|
||||
)
|
||||
ok = panel_client.report_instance_config(
|
||||
URL, TOKEN, r"D:\Alfa\In", instance_key="Alfa"
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["json"]["instance_key"] == "Alfa"
|
||||
|
||||
|
||||
CATALOG_RESPONSE = {
|
||||
"targets": [
|
||||
{"id": 1, "name": "Alfa"},
|
||||
{"id": 2, "name": "Omega"},
|
||||
{"id": 4, "name": "Delta"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_list_restore_target_names_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, CATALOG_RESPONSE)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "get", fake_get)
|
||||
names = panel_client.list_restore_target_names(URL, TOKEN)
|
||||
assert names == ["Alfa", "Omega", "Delta"]
|
||||
assert captured["url"].endswith("/api/restore/target-catalog")
|
||||
|
||||
|
||||
def test_list_restore_target_names_url_vacia():
|
||||
assert panel_client.list_restore_target_names("", TOKEN) == []
|
||||
|
||||
|
||||
def test_list_restore_target_names_token_vacio():
|
||||
assert panel_client.list_restore_target_names(URL, "") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [401, 500, 503])
|
||||
def test_list_restore_target_names_codigos_no_200(monkeypatch, code):
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
|
||||
assert panel_client.list_restore_target_names(URL, TOKEN) == []
|
||||
|
||||
|
||||
def test_list_restore_target_names_error_red(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise panel_client.requests.exceptions.ConnectionError("caído")
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "get", boom)
|
||||
assert panel_client.list_restore_target_names(URL, TOKEN) == []
|
||||
|
||||
|
||||
def test_list_restore_target_names_json_invalido(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
|
||||
)
|
||||
assert panel_client.list_restore_target_names(URL, TOKEN) == []
|
||||
|
||||
|
||||
def test_list_restore_target_names_sin_lista_targets(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, {"foo": []})
|
||||
)
|
||||
assert panel_client.list_restore_target_names(URL, TOKEN) == []
|
||||
|
||||
|
||||
ROUTE_FORWARD = {
|
||||
"action": "forward",
|
||||
"db_name": "GENERICA-TEST",
|
||||
"node_key": "GENERICA-TEST",
|
||||
"target": {
|
||||
"id": 2,
|
||||
"name": "Alfa",
|
||||
"server": "192.168.1.10,1433",
|
||||
"username": "sa",
|
||||
"password": "secreto",
|
||||
"data_folder": "C:\\SQLData",
|
||||
"ssh_host": "192.168.1.10",
|
||||
"ssh_port": 22,
|
||||
"ssh_username": "Administrator",
|
||||
"ssh_password": "ssh-secreto",
|
||||
"remote_inbox_path": "C:\\RestoreInbox",
|
||||
"input_folder": "D:\\Restore\\Alfa\\Entrada",
|
||||
},
|
||||
}
|
||||
|
||||
ROUTE_RESTORE_LOCAL = {
|
||||
"action": "restore_local",
|
||||
"db_name": "GENERICA-TEST",
|
||||
"node_key": "GENERICA-TEST",
|
||||
"target": {
|
||||
"id": 2,
|
||||
"name": "Alfa",
|
||||
"server": "192.168.1.10,1433",
|
||||
"username": "sa",
|
||||
"password": "secreto",
|
||||
"data_folder": "C:\\SQLData",
|
||||
"ssh_host": "192.168.1.10",
|
||||
"ssh_port": 22,
|
||||
"ssh_username": "Administrator",
|
||||
"ssh_password": "ssh-secreto",
|
||||
"remote_inbox_path": "C:\\RestoreInbox",
|
||||
"input_folder": "D:\\Restore\\Alfa\\Entrada",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_route_forward_ok(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
captured["url"] = url
|
||||
return FakeResponse(200, ROUTE_FORWARD)
|
||||
|
||||
monkeypatch.setattr(panel_client.requests, "get", fake_get)
|
||||
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Omega")
|
||||
assert route is not None
|
||||
assert route["action"] == "forward"
|
||||
assert "resolve-route" in captured["url"]
|
||||
assert "instance=Omega" in captured["url"]
|
||||
|
||||
|
||||
def test_resolve_route_restore_local_ok(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, ROUTE_RESTORE_LOCAL)
|
||||
)
|
||||
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Alfa")
|
||||
assert route is not None
|
||||
assert route["action"] == "restore_local"
|
||||
|
||||
|
||||
def test_resolve_route_503_devuelve_none(monkeypatch):
|
||||
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(503))
|
||||
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
|
||||
|
||||
|
||||
def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
|
||||
bad = dict(ROUTE_FORWARD)
|
||||
bad["target"] = dict(bad["target"], input_folder=" ")
|
||||
monkeypatch.setattr(
|
||||
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
|
||||
)
|
||||
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
|
||||
138
tests/test_route_forward.py
Normal file
138
tests/test_route_forward.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Pruebas de enrutamiento automático (restore_local | forward) en RestoreWorker.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.restore_worker import RestoreDeferred, RestoreWorker
|
||||
|
||||
|
||||
@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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_collect_zip_paths_simple(tmp_path, base_config):
|
||||
z = tmp_path / "in" / "NODO.ZIP"
|
||||
z.parent.mkdir(parents=True)
|
||||
z.write_bytes(b"z")
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
paths = worker._collect_zip_paths(str(z))
|
||||
assert paths == [str(z)]
|
||||
|
||||
|
||||
def test_collect_zip_paths_multipart(tmp_path, base_config):
|
||||
folder = tmp_path / "in"
|
||||
folder.mkdir(parents=True)
|
||||
p1 = folder / "NODO.ZIP.001"
|
||||
p2 = folder / "NODO.ZIP.002"
|
||||
p1.write_bytes(b"1")
|
||||
p2.write_bytes(b"2")
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
paths = worker._collect_zip_paths(str(p1))
|
||||
assert len(paths) == 2
|
||||
assert all(Path(p).exists() for p in paths)
|
||||
|
||||
|
||||
def test_resolve_route_envia_instance_key(monkeypatch, base_config):
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
captured = {}
|
||||
|
||||
def fake_resolve(api_url, api_token, filename, instance_key=None):
|
||||
captured["instance_key"] = instance_key
|
||||
return {
|
||||
"action": "restore_local",
|
||||
"db_name": "DB1",
|
||||
"node_key": "NODO",
|
||||
"target": {
|
||||
"id": 1,
|
||||
"name": "Alfa",
|
||||
"server": "localhost",
|
||||
"username": "sa",
|
||||
"password": "x",
|
||||
"data_folder": "C:\\Data",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.panel_client.resolve_route", fake_resolve
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.update_node_and_db", lambda *a, **k: None
|
||||
)
|
||||
|
||||
class FakeJob:
|
||||
source_name = "NODO.ZIP"
|
||||
source_path = "C:\\in\\NODO.ZIP"
|
||||
|
||||
route = worker._resolve_route(FakeJob())
|
||||
assert route["action"] == "restore_local"
|
||||
assert captured["instance_key"] == "Alfa"
|
||||
|
||||
|
||||
def test_resolve_route_sin_instance_key_diferido(base_config):
|
||||
base_config["panel"]["instance_key"] = ""
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
|
||||
class FakeJob:
|
||||
source_name = "NODO.ZIP"
|
||||
source_path = "C:\\in\\NODO.ZIP"
|
||||
|
||||
with pytest.raises(RestoreDeferred, match="instance_key"):
|
||||
worker._resolve_route(FakeJob())
|
||||
|
||||
|
||||
def test_legacy_mode_no_cambia_comportamiento(monkeypatch, base_config):
|
||||
base_config["panel"]["mode"] = "orchestrator"
|
||||
worker = RestoreWorker("job-1", base_config)
|
||||
assert worker._legacy_panel_mode() == "orchestrator"
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_resolve(api_url, api_token, filename, instance_key=None):
|
||||
captured["instance_key"] = instance_key
|
||||
return {
|
||||
"action": "forward",
|
||||
"db_name": "DB1",
|
||||
"node_key": "NODO",
|
||||
"target": {
|
||||
"id": 2,
|
||||
"name": "Omega",
|
||||
"ssh_host": "h",
|
||||
"ssh_username": "u",
|
||||
"ssh_password": "p",
|
||||
"input_folder": "D:\\In",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.panel_client.resolve_route", fake_resolve
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.engine.restore_worker.JobRepository.update_node_and_db", lambda *a, **k: None
|
||||
)
|
||||
|
||||
class FakeJob:
|
||||
source_name = "NODO.ZIP"
|
||||
source_path = "C:\\in\\NODO.ZIP"
|
||||
|
||||
route = worker._resolve_route(FakeJob())
|
||||
assert route["action"] == "forward"
|
||||
assert captured["instance_key"] == "Alfa"
|
||||
135
tests/test_sftp_copy.py
Normal file
135
tests/test_sftp_copy.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
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 pytest
|
||||
|
||||
from app.transfer import sftp_copy
|
||||
from app.transfer.sftp_copy import SFTPCopyError
|
||||
|
||||
CFG = {
|
||||
"ssh_host": "192.168.1.100",
|
||||
"ssh_port": 22,
|
||||
"ssh_username": "Administrator",
|
||||
"ssh_password": "ssh-secreto",
|
||||
"remote_inbox_path": "C:\\RestoreInbox",
|
||||
}
|
||||
|
||||
|
||||
def test_windows_restore_path():
|
||||
assert sftp_copy.windows_restore_path("C:\\RestoreInbox", "empresa.bak") == "C:\\RestoreInbox\\empresa.bak"
|
||||
|
||||
|
||||
def test_sftp_path_convierte_windows_a_posix():
|
||||
# ruta destino en formato Windows → SFTP usa forward slashes
|
||||
assert sftp_copy._sftp_path("C:\\RestoreInbox", "empresa.bak") == "C:/RestoreInbox/empresa.bak"
|
||||
# ya en posix se respeta (sin doble slash)
|
||||
assert sftp_copy._sftp_path("/srv/inbox/", "x.bak") == "/srv/inbox/x.bak"
|
||||
|
||||
|
||||
def test_upload_origen_inexistente(tmp_path):
|
||||
with pytest.raises(SFTPCopyError, match="no existe"):
|
||||
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
|
||||
|
||||
|
||||
class FakeSFTP:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def put(self, local, remote):
|
||||
self.store["put"] = (local, remote)
|
||||
|
||||
def remove(self, remote):
|
||||
self.store["removed"] = remote
|
||||
|
||||
def close(self):
|
||||
self.store["sftp_closed"] = True
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
self.store["policy_set"] = True
|
||||
|
||||
def connect(self, **kwargs):
|
||||
self.store["connect"] = kwargs
|
||||
|
||||
def open_sftp(self):
|
||||
return FakeSFTP(self.store)
|
||||
|
||||
def close(self):
|
||||
self.store["client_closed"] = True
|
||||
|
||||
|
||||
def _fake_paramiko(store):
|
||||
class FakeParamiko:
|
||||
AutoAddPolicy = object
|
||||
SSHClient = lambda self=None: FakeClient(store)
|
||||
fp = FakeParamiko()
|
||||
# SSHClient() debe devolver FakeClient
|
||||
fp.SSHClient = lambda: FakeClient(store)
|
||||
return fp
|
||||
|
||||
|
||||
def test_upload_ok(tmp_path, monkeypatch):
|
||||
bak = tmp_path / "empresa.bak"
|
||||
bak.write_bytes(b"data")
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
|
||||
|
||||
remote = sftp_copy.upload_to_remote(str(bak), CFG)
|
||||
assert remote == "C:/RestoreInbox/empresa.bak"
|
||||
assert store["put"][1] == "C:/RestoreInbox/empresa.bak"
|
||||
assert store["connect"]["hostname"] == "192.168.1.100"
|
||||
assert store["connect"]["port"] == 22
|
||||
assert store["client_closed"] is True
|
||||
|
||||
|
||||
def test_cleanup_remote_borra(monkeypatch):
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
|
||||
sftp_copy.cleanup_remote(CFG, "C:/RestoreInbox/empresa.bak")
|
||||
assert store["removed"] == "C:/RestoreInbox/empresa.bak"
|
||||
assert store["client_closed"] is True
|
||||
|
||||
|
||||
def test_cleanup_remote_none_no_falla():
|
||||
sftp_copy.cleanup_remote(CFG, None) # no debe conectar ni lanzar
|
||||
|
||||
|
||||
def test_upload_file_to_folder_ok(tmp_path, monkeypatch):
|
||||
zf = tmp_path / "backup.zip"
|
||||
zf.write_bytes(b"zipdata")
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
|
||||
|
||||
remote = sftp_copy.upload_file_to_folder(
|
||||
str(zf), CFG, "D:\\Restore\\Alfa\\Entrada"
|
||||
)
|
||||
assert remote == "D:/Restore/Alfa/Entrada/backup.zip"
|
||||
assert store["put"][1] == "D:/Restore/Alfa/Entrada/backup.zip"
|
||||
|
||||
|
||||
def test_upload_zip_parts_multipart(tmp_path, monkeypatch):
|
||||
p1 = tmp_path / "big.zip.001"
|
||||
p2 = tmp_path / "big.zip.002"
|
||||
p1.write_bytes(b"a")
|
||||
p2.write_bytes(b"b")
|
||||
store: dict = {}
|
||||
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
|
||||
|
||||
uploaded = sftp_copy.upload_zip_parts(
|
||||
[str(p1), str(p2)], CFG, "D:\\In"
|
||||
)
|
||||
assert len(uploaded) == 2
|
||||
assert uploaded[0].endswith("/big.zip.001")
|
||||
assert uploaded[1].endswith("/big.zip.002")
|
||||
|
||||
|
||||
def test_upload_file_to_folder_vacio_falla(tmp_path):
|
||||
f = tmp_path / "x.zip"
|
||||
f.write_bytes(b"x")
|
||||
with pytest.raises(SFTPCopyError, match="carpeta remota"):
|
||||
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
|
||||
77
tests/test_sql_manager_move.py
Normal file
77
tests/test_sql_manager_move.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Pruebas de _build_move_clauses: nombres únicos por archivo lógico (G4),
|
||||
evitando colisiones con múltiples data files / logs y sin descartar otros tipos.
|
||||
"""
|
||||
from app.sql.sql_manager import SQLServerManager, LogicalFile
|
||||
|
||||
|
||||
def _paths(clauses):
|
||||
"""Extrae las rutas destino (lo que va tras 'TO N') de cada cláusula MOVE."""
|
||||
out = []
|
||||
for c in clauses:
|
||||
# MOVE N'logico' TO N'ruta'
|
||||
ruta = c.split("TO N'")[1].rstrip("'")
|
||||
out.append(ruta)
|
||||
return out
|
||||
|
||||
|
||||
def test_un_data_un_log():
|
||||
files = [
|
||||
LogicalFile("EMP_dat", "X.mdf", "D"),
|
||||
LogicalFile("EMP_log", "X.ldf", "L"),
|
||||
]
|
||||
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
|
||||
rutas = _paths(clauses)
|
||||
assert rutas == ["C:\\D\\EMP.mdf", "C:\\D\\EMP_log.ldf"]
|
||||
|
||||
|
||||
def test_multiples_data_files_sin_colision():
|
||||
files = [
|
||||
LogicalFile("d1", "a.mdf", "D"),
|
||||
LogicalFile("d2", "b.ndf", "D"),
|
||||
LogicalFile("d3", "c.ndf", "D"),
|
||||
LogicalFile("l1", "a.ldf", "L"),
|
||||
]
|
||||
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
|
||||
rutas = _paths(clauses)
|
||||
assert rutas == [
|
||||
"C:\\D\\EMP.mdf",
|
||||
"C:\\D\\EMP_1.ndf",
|
||||
"C:\\D\\EMP_2.ndf",
|
||||
"C:\\D\\EMP_log.ldf",
|
||||
]
|
||||
# No debe haber rutas duplicadas (la causa del bug original).
|
||||
assert len(set(rutas)) == len(rutas)
|
||||
|
||||
|
||||
def test_multiples_logs_sin_colision():
|
||||
files = [
|
||||
LogicalFile("d1", "a.mdf", "D"),
|
||||
LogicalFile("l1", "a.ldf", "L"),
|
||||
LogicalFile("l2", "b.ldf", "L"),
|
||||
]
|
||||
rutas = _paths(SQLServerManager._build_move_clauses("EMP", "C:\\D", files))
|
||||
assert rutas == ["C:\\D\\EMP.mdf", "C:\\D\\EMP_log.ldf", "C:\\D\\EMP_log_1.ldf"]
|
||||
assert len(set(rutas)) == len(rutas)
|
||||
|
||||
|
||||
def test_tipo_no_data_ni_log_no_se_descarta():
|
||||
# Tipos como FILESTREAM/full-text (p. ej. 'S') deben moverse, no ignorarse.
|
||||
files = [
|
||||
LogicalFile("d1", "a.mdf", "D"),
|
||||
LogicalFile("l1", "a.ldf", "L"),
|
||||
LogicalFile("fs stream!", "fs", "S"),
|
||||
]
|
||||
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
|
||||
assert len(clauses) == 3 # ninguno descartado
|
||||
rutas = _paths(clauses)
|
||||
# El nombre lógico se sanea (no alfanumérico → '_').
|
||||
assert "C:\\D\\EMP_fs_stream_" in rutas[2]
|
||||
assert len(set(rutas)) == len(rutas)
|
||||
|
||||
|
||||
def test_cada_archivo_logico_genera_una_clausula():
|
||||
files = [LogicalFile(f"f{i}", f"f{i}", "D") for i in range(5)]
|
||||
clauses = SQLServerManager._build_move_clauses("DB", "C:\\D", files)
|
||||
assert len(clauses) == 5
|
||||
assert len(set(_paths(clauses))) == 5 # todas únicas
|
||||
Reference in New Issue
Block a user