feat(files): orchestrate connector folder sync
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 22:49:47 +02:00
parent a8c3192c3a
commit 378f4d6ac5
19 changed files with 1302 additions and 30 deletions
+303
View File
@@ -0,0 +1,303 @@
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()
+13
View File
@@ -154,6 +154,11 @@ class FilesManifestDocumentationTests(unittest.TestCase):
"files.connectors",
"files.connector.credentials",
"files.connector.policy",
"files.connector-folder-sync.remote-path",
"files.connector-folder-sync.target-folder",
"files.connector-folder-sync.conflict-strategy",
"files.connector-folder-sync.max-files",
"files.connector-folder-sync.recursive",
},
set(topic.metadata["help_contexts"]),
)
@@ -162,6 +167,9 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("same transaction", topic.body)
self.assertIn("read-only by default", topic.body)
self.assertIn("S3", topic.body)
self.assertIn("manual connector-folder sync", topic.body)
self.assertIn("Per-file savepoints", topic.body)
self.assertIn("files.connector.folder_synced", topic.body)
self.assertIn("Automatic remote deletion", topic.body)
self.assertTrue(
any(
@@ -176,6 +184,11 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn(
"/api/v1/files/connectors/credentials", {link.href for link in topic.links}
)
self.assertIn(
"/api/v1/files/connector-spaces/{space_id}/sync",
{link.href for link in topic.links},
)
self.assertIn("de", topic.translations)
def test_operator_topic_covers_recovery_and_pinned_s3_smb(self) -> None:
topic = self.topic(
+2 -1
View File
@@ -57,7 +57,7 @@ class FilesRouterContractTests(unittest.TestCase):
actual = self._operation_keys(router)
self.assertEqual(expected, actual)
self.assertEqual(62, len(actual))
self.assertEqual(63, len(actual))
self.assertFalse(
[operation for operation, count in Counter(actual).items() if count > 1]
)
@@ -83,6 +83,7 @@ class FilesRouterContractTests(unittest.TestCase):
(("GET",), "/files/connectors/profiles/{profile_id}/browse"),
(("POST",), "/files/connectors/profiles/{profile_id}/import"),
(("POST",), "/files/connectors/profiles/{profile_id}/sync"),
(("POST",), "/files/connector-spaces/{space_id}/sync"),
(("POST",), "/files/connector-spaces/{space_id}/write-back"),
(("GET",), "/files/connectors/credentials"),
(("POST",), "/files/connectors/credentials"),