from __future__ import annotations import unittest from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch from govoplan_files.backend.routes.connector_io import sync_connector_space_folder from govoplan_files.backend.schemas import ( FileAssetResponse, FileConnectorFolderSyncRequest, ) from govoplan_files.backend.storage.common import FileStorageError, UploadedStoredFile from govoplan_files.backend.storage.connector_browse import ConnectorBrowseItem from govoplan_files.backend.storage.connector_folder_sync import ( connector_relative_path, discover_connector_folder, ) from govoplan_files.backend.storage.connector_imports import ConnectorDownloadedFile from govoplan_files.backend.storage.connector_policy import ConnectorPolicyDenied from govoplan_files.backend.storage.connector_profiles import ConnectorProfile def _item(kind: str, path: str, *, token: str | None = None) -> ConnectorBrowseItem: return ConnectorBrowseItem( kind=kind, name=path.rsplit("/", 1)[-1], path=path, external_id=f"external:{path}", etag=f"etag:{path}", metadata={"next_continuation_token": token} if token else {}, ) class ConnectorFolderDiscoveryTests(unittest.TestCase): def setUp(self) -> None: self.profile = ConnectorProfile( id="profile-1", label="Documents", provider="webdav", capabilities=("browse", "sync"), ) @patch("govoplan_files.backend.storage.connector_folder_sync.browse_connector_profile") def test_recursively_discovers_files_across_pages_in_deterministic_order( self, browse: Mock ) -> None: browse.side_effect = [ [_item("file", "root/z.txt", token="page-2")], [_item("folder", "root/nested"), _item("file", "root/a.txt")], [_item("file", "root/nested/b.txt")], ] result = discover_connector_folder( self.profile, path="root", library_id="library-1", recursive=True, max_files=10, max_depth=3, ) self.assertEqual( ["root/a.txt", "root/nested/b.txt", "root/z.txt"], [item.path for item in result.files], ) self.assertFalse(result.truncated) self.assertEqual([], result.skipped) self.assertEqual( [ call( self.profile, path="root", library_id="library-1", continuation_token=None, ), call( self.profile, path="root", library_id="library-1", continuation_token="page-2", ), call( self.profile, path="root/nested", library_id="library-1", continuation_token=None, ), ], browse.call_args_list, ) @patch("govoplan_files.backend.storage.connector_folder_sync.browse_connector_profile") def test_non_recursive_and_file_limit_skips_are_explicit(self, browse: Mock) -> None: browse.return_value = [ _item("folder", "root/nested"), _item("file", "root/a.txt"), _item("file", "root/b.txt"), ] result = discover_connector_folder( self.profile, path="root", library_id=None, recursive=False, max_files=1, max_depth=0, ) self.assertEqual(["root/a.txt"], [item.path for item in result.files]) self.assertTrue(result.truncated) self.assertEqual( ["root/nested", "root/b.txt"], [item.path for item in result.skipped], ) def test_relative_path_rejects_provider_results_outside_linked_root(self) -> None: self.assertEqual( "nested/report.csv", connector_relative_path( space_root="linked/root", item_path="linked/root/nested/report.csv" ), ) with self.assertRaisesRegex(ValueError, "outside"): connector_relative_path( space_root="linked/root", item_path="another/root/report.csv" ) class ConnectorFolderSyncRouteTests(unittest.TestCase): def setUp(self) -> None: self.session = MagicMock() self.space = SimpleNamespace( id="space-1", tenant_id="tenant-1", owner_type="user", owner_user_id="user-1", owner_group_id=None, connector_profile_id="profile-1", provider="webdav", library_id="library-1", remote_path="linked/root", sync_mode="manual", ) self.profile = ConnectorProfile( id="profile-1", label="Documents", provider="webdav", capabilities=("browse", "sync"), ) self.principal = SimpleNamespace( tenant_id="tenant-1", user=SimpleNamespace(id="user-1") ) @staticmethod def _stored(index: int) -> UploadedStoredFile: return UploadedStoredFile( asset=SimpleNamespace(id=f"asset-{index}"), version=SimpleNamespace(id=f"version-{index}"), blob=SimpleNamespace(id=f"blob-{index}"), ) @staticmethod def _response(index: int, action: str) -> FileAssetResponse: now = datetime(2026, 8, 21, tzinfo=UTC).isoformat() return FileAssetResponse( id=f"asset-{index}", tenant_id="tenant-1", owner_type="user", owner_id="user-1", display_path=f"Imports/file-{index}.txt", filename=f"file-{index}.txt", size_bytes=index, checksum_sha256=str(index) * 64, version_id=f"version-{index}", created_at=now, updated_at=now, source_revision=f"revision-{action}", ) @patch("govoplan_files.backend.routes.connector_io.audit_from_principal") @patch("govoplan_files.backend.routes.connector_io._asset_response") @patch("govoplan_files.backend.routes.connector_io._audit_connector_sync") @patch("govoplan_files.backend.routes.connector_io.sync_file_asset_from_source") @patch("govoplan_files.backend.routes.connector_io._download_connector_payload") @patch("govoplan_files.backend.routes.connector_io.discover_connector_folder") @patch("govoplan_files.backend.routes.connector_io._connector_space_policy_decision") @patch("govoplan_files.backend.routes.connector_io._visible_connector_profile") @patch("govoplan_files.backend.routes.connector_io.get_connector_space_for_user") @patch("govoplan_files.backend.routes.connector_io._is_admin", return_value=False) def test_returns_reviewable_per_item_results_and_audits_the_summary( self, _is_admin: Mock, get_space: Mock, visible_profile: Mock, policy_decision: Mock, discover: Mock, download: Mock, sync_file: Mock, audit_sync: Mock, asset_response: Mock, audit: Mock, ) -> None: get_space.return_value = self.space visible_profile.return_value = self.profile policy_decision.return_value = SimpleNamespace(allowed=True) sources = [_item("file", f"linked/root/file-{index}.txt") for index in range(1, 6)] discover.return_value = SimpleNamespace( files=sources, skipped=[SimpleNamespace(path="linked/root/ignored", reason="Unsupported item")], truncated=False, ) downloads = [ ( source.path, ConnectorDownloadedFile( filename=source.name, data=source.name.encode(), revision=source.etag, ), {"source_provenance": {"connector_id": "profile-1"}}, ) for source in sources ] denied_decision = SimpleNamespace( reason="Path denied", to_dict=Mock(return_value={"allowed": False, "reason": "Path denied"}), ) downloads[-1] = ConnectorPolicyDenied(denied_decision) # type: ignore[assignment] download.side_effect = downloads sync_file.side_effect = [ (self._stored(1), "created", None), (self._stored(2), "updated", "previous-2"), (self._stored(3), "unchanged", "previous-3"), FileStorageError("Target file already exists: Imports/file-4.txt"), ] asset_response.side_effect = [ self._response(1, "created"), self._response(2, "updated"), self._response(3, "unchanged"), ] response = sync_connector_space_folder( "space-1", FileConnectorFolderSyncRequest(target_folder="Imports"), session=self.session, principal=self.principal, # type: ignore[arg-type] ) self.assertEqual( { "discovered": 5, "created": 1, "updated": 1, "unchanged": 1, "skipped": 1, "conflicts": 1, "policy_denied": 1, "failed": 0, }, response.summary.model_dump(), ) self.assertEqual( [ "skipped", "created", "updated", "unchanged", "conflict", "policy_denied", ], [item.action for item in response.items], ) self.assertEqual("Imports/file-1.txt", response.items[1].target_path) self.assertEqual( {"allowed": False, "reason": "Path denied"}, response.items[-1].policy_decision, ) self.session.commit.assert_called_once_with() audit_sync.assert_has_calls( [ call( self.session, self.principal, self._stored(index).asset, sync_action=action, previous_version_id=previous, ) for index, action, previous in ( (1, "created", None), (2, "updated", "previous-2"), (3, "unchanged", "previous-3"), ) ] ) summary_details = audit.call_args.kwargs["details"] self.assertEqual(response.summary.model_dump(), summary_details["summary"]) self.assertEqual(6, len(summary_details["results"])) self.assertNotIn("detail", summary_details["results"][-1]) if __name__ == "__main__": unittest.main()