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)