feature/integracion-panel-restore-targets

This commit is contained in:
2026-06-05 10:49:05 -06:00
parent 854ffa116f
commit afb76ce4a6
20 changed files with 2022 additions and 104 deletions

5
tests/conftest.py Normal file
View 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__))))

View 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
View 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
View 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
View 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, " ")

View 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