mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
304 lines
9.6 KiB
Python
304 lines
9.6 KiB
Python
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from backend.pashub_fetcher.sharepoint_subfolders import SharepointSubfolders
|
|
from orchestration.sharepoint_renamer_orchestrator import (
|
|
ASSESSMENT_SUBFOLDER,
|
|
BASE_PATH,
|
|
SharepointRenamerOrchestrator,
|
|
build_canonical_filename,
|
|
)
|
|
|
|
|
|
def _assessment_path(root: str, address: str, postcode: str) -> str:
|
|
return (
|
|
f"{root}/{address}, {postcode}"
|
|
f"/{SharepointSubfolders.ASSESSMENT.value}/{ASSESSMENT_SUBFOLDER}"
|
|
)
|
|
|
|
|
|
def _write_csv(tmp_path: Path, rows: list[tuple[str, str, str]]) -> str:
|
|
lines = ["UPRN,Address,Postcode"] + [",".join(row) for row in rows]
|
|
csv_file = tmp_path / "addresses.csv"
|
|
csv_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return str(csv_file)
|
|
|
|
|
|
def _make_file(name: str, item_id: str = "id-1") -> dict[str, Any]:
|
|
return {"name": name, "id": item_id, "file": {}}
|
|
|
|
|
|
def _make_folder(name: str) -> dict[str, Any]:
|
|
return {"name": name, "folder": {}}
|
|
|
|
|
|
def _make_package(name: str) -> dict[str, Any]:
|
|
return {"name": name, "package": {}}
|
|
|
|
|
|
def _make_orchestrator(sp: MagicMock, dry_run: bool = False) -> SharepointRenamerOrchestrator:
|
|
orchestrator = SharepointRenamerOrchestrator.__new__(SharepointRenamerOrchestrator)
|
|
orchestrator._sp_client = sp
|
|
orchestrator._dry_run = dry_run
|
|
return orchestrator
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_canonical_filename
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_already_canonical_returns_none() -> None:
|
|
assert build_canonical_filename("100", "1 High St", "AB1 2CD", "100_High St AB1 2CD_Report.pdf") is None
|
|
|
|
|
|
def test_strips_address_prefix_and_adds_uprn() -> None:
|
|
result = build_canonical_filename("100", "1 High St", "AB1 2CD", "1 High St AB1 2CD - Survey.pdf")
|
|
assert result == "100_1 High St AB1 2CD_Survey.pdf"
|
|
|
|
|
|
def test_no_prefix_still_canonical() -> None:
|
|
result = build_canonical_filename("100", "1 High St", "AB1 2CD", "Survey.pdf")
|
|
assert result == "100_1 High St AB1 2CD_Survey.pdf"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _discover_roots — batch subfolders become extra roots
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_discover_roots_includes_sero_batch_folders() -> None:
|
|
# Arrange
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.return_value = {
|
|
"value": [
|
|
_make_folder("1 High St, AB1 2CD"),
|
|
_make_folder("_Sero Batch 3"),
|
|
_make_folder("_Sero Batch 2"),
|
|
_make_file("stray.pdf"),
|
|
]
|
|
}
|
|
|
|
# Act
|
|
roots = _make_orchestrator(sp)._discover_roots()
|
|
|
|
# Assert
|
|
assert roots == [
|
|
BASE_PATH,
|
|
f"{BASE_PATH}/_Sero Batch 2",
|
|
f"{BASE_PATH}/_Sero Batch 3",
|
|
]
|
|
sp.get_folders_in_path.assert_called_once_with(BASE_PATH)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run — properties under a batch root are still found and renamed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_renames_files_for_property_under_batch_root(tmp_path: Path) -> None:
|
|
# Arrange
|
|
csv_path = _write_csv(tmp_path, [("100", "1 High St", "AB1 2CD")])
|
|
batch_assessment = _assessment_path(
|
|
f"{BASE_PATH}/_Sero Batch 2", "1 High St", "AB1 2CD"
|
|
)
|
|
|
|
sp = MagicMock()
|
|
|
|
def fake_get(path: str) -> dict[str, Any]:
|
|
if path == BASE_PATH:
|
|
return {"value": [_make_folder("_Sero Batch 2")]}
|
|
if path == batch_assessment:
|
|
return {"value": [_make_file("Survey.pdf", "id-1")]}
|
|
raise ValueError("not found")
|
|
|
|
sp.get_folders_in_path.side_effect = fake_get
|
|
|
|
# Act
|
|
SharepointRenamerOrchestrator(sp, csv_path).run()
|
|
|
|
# Assert
|
|
sp.rename_file.assert_called_once_with("id-1", "100_1 High St AB1 2CD_Survey.pdf")
|
|
|
|
|
|
def test_run_uprn_prefixed_address_yields_single_uprn_filename(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# Arrange
|
|
csv_path = _write_csv(tmp_path, [("100", "100_1 High St", "AB1 2CD")])
|
|
batch_assessment = _assessment_path(
|
|
f"{BASE_PATH}/_Sero Batch 2", "100_1 High St", "AB1 2CD"
|
|
)
|
|
|
|
sp = MagicMock()
|
|
|
|
def fake_get(path: str) -> dict[str, Any]:
|
|
if path == BASE_PATH:
|
|
return {"value": [_make_folder("_Sero Batch 2")]}
|
|
if path == batch_assessment:
|
|
return {"value": [_make_file("Survey.pdf", "id-1")]}
|
|
raise ValueError("not found")
|
|
|
|
sp.get_folders_in_path.side_effect = fake_get
|
|
|
|
# Act
|
|
SharepointRenamerOrchestrator(sp, csv_path).run()
|
|
|
|
# Assert
|
|
sp.rename_file.assert_called_once_with("id-1", "100_1 High St AB1 2CD_Survey.pdf")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — files only at root level
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_renames_top_level_files(caplog: pytest.LogCaptureFixture) -> None:
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.return_value = {
|
|
"value": [
|
|
_make_file("Survey.pdf", "id-1"),
|
|
_make_file("Report.docx", "id-2"),
|
|
]
|
|
}
|
|
|
|
_make_orchestrator(sp)._process_folder("some/path", "100", "1 High St", "AB1 2CD")
|
|
|
|
assert sp.rename_file.call_count == 2
|
|
sp.rename_file.assert_any_call("id-1", "100_1 High St AB1 2CD_Survey.pdf")
|
|
sp.rename_file.assert_any_call("id-2", "100_1 High St AB1 2CD_Report.docx")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — recursive two-level hierarchy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recurses_into_subfolders_and_renames_all_files() -> None:
|
|
sp = MagicMock()
|
|
|
|
root_contents: dict[str, Any] = {
|
|
"value": [
|
|
_make_file("Root.pdf", "root-file"),
|
|
_make_folder("SubA"),
|
|
]
|
|
}
|
|
suba_contents: dict[str, Any] = {
|
|
"value": [
|
|
_make_file("Sub.pdf", "sub-file"),
|
|
]
|
|
}
|
|
|
|
sp.get_folders_in_path.side_effect = lambda path: (
|
|
root_contents if path == "base/path" else suba_contents
|
|
)
|
|
|
|
_make_orchestrator(sp)._process_folder("base/path", "200", "2 Main Rd", "XY9 8ZW")
|
|
|
|
assert sp.rename_file.call_count == 2
|
|
sp.rename_file.assert_any_call("root-file", "200_2 Main Rd XY9 8ZW_Root.pdf")
|
|
sp.rename_file.assert_any_call("sub-file", "200_2 Main Rd XY9 8ZW_Sub.pdf")
|
|
|
|
sp.get_folders_in_path.assert_any_call("base/path/SubA")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — non-file, non-folder items are skipped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ignores_package_items() -> None:
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.return_value = {"value": [_make_package("Notebook")]}
|
|
|
|
_make_orchestrator(sp)._process_folder("some/path", "300", "3 Oak Ave", "ZZ1 1ZZ")
|
|
|
|
sp.rename_file.assert_not_called()
|
|
assert sp.get_folders_in_path.call_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — missing folder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_missing_folder_returns_false_without_warning(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
# Arrange
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.side_effect = ValueError("not found")
|
|
|
|
# Act
|
|
found = _make_orchestrator(sp)._process_folder(
|
|
"missing/path", "400", "4 Elm St", "AA2 2BB"
|
|
)
|
|
|
|
# Assert
|
|
assert found is False
|
|
sp.rename_file.assert_not_called()
|
|
assert not any("Missing folder" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_run_warns_once_when_property_missing_from_all_roots(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
# Arrange
|
|
csv_path = _write_csv(tmp_path, [("400", "4 Elm St", "AA2 2BB")])
|
|
sp = MagicMock()
|
|
|
|
def fake_get(path: str) -> dict[str, Any]:
|
|
if path == BASE_PATH:
|
|
return {"value": [_make_folder("_Sero Batch 2")]}
|
|
raise ValueError("not found")
|
|
|
|
sp.get_folders_in_path.side_effect = fake_get
|
|
|
|
# Act
|
|
SharepointRenamerOrchestrator(sp, csv_path).run()
|
|
|
|
# Assert
|
|
sp.rename_file.assert_not_called()
|
|
warnings = [r for r in caplog.records if "Missing folder" in r.message]
|
|
assert len(warnings) == 1
|
|
assert "400" in warnings[0].message
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — already-canonical files are skipped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_skips_already_canonical_files() -> None:
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.return_value = {
|
|
"value": [_make_file("500_Pine Ln BB3 3CC_Doc.pdf", "id-y")]
|
|
}
|
|
|
|
_make_orchestrator(sp)._process_folder("some/path", "500", "5 Pine Ln", "BB3 3CC")
|
|
|
|
sp.rename_file.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_folder — dry_run=True logs intent but never calls rename_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dry_run_logs_would_rename_without_calling_api(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
sp = MagicMock()
|
|
sp.get_folders_in_path.return_value = {
|
|
"value": [_make_file("Survey.pdf", "id-1")]
|
|
}
|
|
|
|
_make_orchestrator(sp, dry_run=True)._process_folder(
|
|
"some/path", "100", "1 High St", "AB1 2CD"
|
|
)
|
|
|
|
sp.rename_file.assert_not_called()
|
|
assert any("Would rename" in r.message for r in caplog.records)
|