Files
dify/api/tests/unit_tests/services/test_app_asset_service.py
yyh 12ca422c8a fix(app-assets): restore atomic batch upload for nested folder targets
The previous nested folder upload flow bypassed the backend batch-upload
contract when parentId was set. Instead of creating the whole metadata
tree in one backend operation, the frontend recursively called
createFolder/getFileUploadUrl for each node.

That introduced two regressions for uploads into subfolders:

- consistency regression: mid-sequence failures could leave partially
  created folder trees under the destination folder
- performance regression: metadata creation degraded from a single
  batch request to O(files + folders) round-trips before file bytes
  were uploaded

This change moves nested uploads back to the original batch semantics:

- add optional parent_id support to app asset batch-upload payload
- create the whole nested tree under the target parent in
  AppAssetService.batch_create_from_tree
- pass parentId through useBatchUpload instead of using per-node
  createFolder/getFileUploadUrl calls
- remove the now-unnecessary useBatchUploadOperation wrapper
- add a backend unit test covering batch tree creation under an
  existing parent folder

After this change, both root uploads and subfolder uploads use the same
single-request metadata creation path, preserving atomic tree creation
semantics and avoiding avoidable metadata round-trips.
2026-03-26 15:16:55 +08:00

87 lines
2.8 KiB
Python

from types import SimpleNamespace
from core.app.entities.app_asset_entities import AppAssetFileTree, AppAssetNode, AssetNodeType, BatchUploadNode
from core.app_assets.storage import AssetPaths
from services import app_asset_service
from services.app_asset_service import AppAssetService
class DummyLock:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class DummySession:
committed: bool
def __init__(self) -> None:
self.committed = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def commit(self) -> None:
self.committed = True
class DummyStorage:
keys: list[str]
def __init__(self) -> None:
self.keys = []
def get_upload_url(self, key: str, expires_in: int) -> str:
self.keys.append(key)
return f"https://upload.local/{key}?expires_in={expires_in}"
def test_batch_create_from_tree_creates_nodes_under_parent(monkeypatch):
session = DummySession()
storage = DummyStorage()
tenant_id = "11111111-1111-4111-8111-111111111111"
app_id = "22222222-2222-4222-8222-222222222222"
parent_folder = AppAssetNode.create_folder("33333333-3333-4333-8333-333333333333", "existing-parent")
assets = SimpleNamespace(asset_tree=AppAssetFileTree(nodes=[parent_folder]), updated_by=None)
app_model = SimpleNamespace(id=app_id, tenant_id=tenant_id)
monkeypatch.setattr(AppAssetService, "_lock", staticmethod(lambda _app_id: DummyLock()))
monkeypatch.setattr(app_asset_service, "db", SimpleNamespace(engine=object()))
monkeypatch.setattr(app_asset_service, "Session", lambda *args, **kwargs: session)
monkeypatch.setattr(
AppAssetService,
"get_or_create_assets",
staticmethod(lambda _session, _app_model, _account_id: assets),
)
monkeypatch.setattr(AppAssetService, "get_storage", staticmethod(lambda: storage))
result = AppAssetService.batch_create_from_tree(
app_model,
"account-1",
[
BatchUploadNode(
name="docs",
node_type=AssetNodeType.FOLDER,
children=[
BatchUploadNode(name="guide.md", node_type=AssetNodeType.FILE, size=12),
],
),
],
parent_id=parent_folder.id,
)
created_folder = next(node for node in assets.asset_tree.nodes if node.name == "docs")
created_file = next(node for node in assets.asset_tree.nodes if node.name == "guide.md")
assert created_folder.parent_id == parent_folder.id
assert created_file.parent_id == created_folder.id
assert created_file.id == result[0].children[0].id
assert assets.updated_by == "account-1"
assert session.committed is True
assert storage.keys == [AssetPaths.draft(tenant_id, app_id, created_file.id)]