gateway/tests/unit/routes/test_folder_crud.py

392 lines
15 KiB
Python

# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Unit tests for folder CRUD operations in ComponentObjects."""
import uuid
import pytest
from unittest.mock import Mock, patch, MagicMock
from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelFiles import FileFolder, FileItem
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.interfaces.interfaceDbManagement import ComponentObjects, FileNotFoundError
_MANDATE_ID = "mandate-test-1"
_FEATURE_INSTANCE_ID = "fi-test-1"
_USER_ID = "user-a-id"
# ── Fakes & helpers ──────────────────────────────────────────────────────────
class _FakeDb:
"""In-memory database mock that mimics DatabaseConnector for unit tests."""
def __init__(self):
self._tables: Dict[str, Dict[str, Dict[str, Any]]] = {}
self.connection = MagicMock()
def getRecordset(self, modelClass, recordFilter=None):
tableName = modelClass.__name__
records = list(self._tables.get(tableName, {}).values())
if not recordFilter:
return records
return [
r for r in records
if all(r.get(k) == v for k, v in recordFilter.items())
]
def recordCreate(self, modelClass, data):
tableName = modelClass.__name__
self._tables.setdefault(tableName, {})
rec = data.model_dump() if hasattr(data, "model_dump") else dict(data)
rec.setdefault("id", str(uuid.uuid4()))
self._tables[tableName][rec["id"]] = rec
return rec
def recordModify(self, modelClass, recordId, updates):
tableName = modelClass.__name__
tbl = self._tables.get(tableName, {})
if recordId in tbl:
tbl[recordId].update(updates)
return True
return False
def recordDelete(self, modelClass, recordId):
tableName = modelClass.__name__
tbl = self._tables.get(tableName, {})
if recordId in tbl:
del tbl[recordId]
return True
return False
def updateContext(self, userId):
pass
def _ensure_connection(self):
pass
def _ensureTableExists(self, modelClass):
return True
def seed(self, modelClass, record: Dict[str, Any]):
tableName = modelClass.__name__
self._tables.setdefault(tableName, {})
self._tables[tableName][record["id"]] = dict(record)
def _makeUser(userId=_USER_ID, username="testuser"):
return User(id=userId, username=username, language="en")
def _makeRbac(
createLevel=AccessLevel.ALL,
readLevel=AccessLevel.ALL,
updateLevel=AccessLevel.ALL,
deleteLevel=AccessLevel.ALL,
):
rbac = Mock()
perms = UserPermissions(
view=True,
read=readLevel,
create=createLevel,
update=updateLevel,
delete=deleteLevel,
)
rbac.getUserPermissions.return_value = perms
return rbac
def _buildComponent(
userId=_USER_ID,
fakeDb=None,
rbac=None,
mandateId=_MANDATE_ID,
featureInstanceId=_FEATURE_INSTANCE_ID,
):
"""Construct a ComponentObjects with mocked internals (no real DB)."""
with patch.object(ComponentObjects, "__init__", lambda self: None):
comp = ComponentObjects()
comp.db = fakeDb or _FakeDb()
comp.currentUser = _makeUser(userId)
comp.userId = userId
comp.mandateId = mandateId
comp.featureInstanceId = featureInstanceId
comp.rbac = rbac or _makeRbac()
comp.userLanguage = "en"
return comp
def _rbacFromFakeDb(fakeDb):
"""Side-effect for getRecordsetWithRBAC that delegates to _FakeDb."""
def _fn(connector, modelClass, currentUser, recordFilter=None, **kwargs):
return fakeDb.getRecordset(modelClass, recordFilter=recordFilter)
return _fn
def _makeFolder(
folderId=None, name="Folder", parentId=None,
userId=_USER_ID, scope="personal", neutralize=False,
):
return {
"id": folderId or str(uuid.uuid4()),
"name": name,
"parentId": parentId,
"mandateId": _MANDATE_ID,
"featureInstanceId": _FEATURE_INSTANCE_ID,
"scope": scope,
"neutralize": neutralize,
"sysCreatedBy": userId,
"sysCreatedAt": 1700000000.0,
"sysModifiedAt": 1700000000.0,
"sysModifiedBy": None,
}
def _makeFile(fileId=None, folderId=None, userId=_USER_ID, scope="personal"):
return {
"id": fileId or str(uuid.uuid4()),
"fileName": "test.txt",
"mimeType": "text/plain",
"fileHash": "abc123",
"fileSize": 100,
"folderId": folderId,
"mandateId": _MANDATE_ID,
"featureInstanceId": _FEATURE_INSTANCE_ID,
"scope": scope,
"neutralize": False,
"sysCreatedBy": userId,
"sysCreatedAt": 1700000000.0,
"sysModifiedAt": 1700000000.0,
"sysModifiedBy": None,
"tags": None,
"description": None,
"status": None,
}
# ── Test class ───────────────────────────────────────────────────────────────
@patch("modules.interfaces.interfaceDbManagement.getRecordsetWithRBAC")
class TestFolderCrud:
"""Tests for folder create / rename / move / delete / patch operations."""
# ── Create ────────────────────────────────────────────────────────────
def testCreateFolderHappyPath(self, mockRbacGet):
fakeDb = _FakeDb()
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.createFolder("Test Folder")
assert result["name"] == "Test Folder"
assert result["scope"] == "personal"
assert result["parentId"] is None
assert result["mandateId"] == _MANDATE_ID
def testCreateFolderWithParent(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="parent-1", name="Parent"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.createFolder("Child Folder", parentId="parent-1")
assert result["name"] == "Child Folder"
assert result["parentId"] == "parent-1"
def testCreateFolderMissingNameNoInterfaceValidation(self, mockRbacGet):
"""Interface does not validate empty name; the route layer returns 400."""
fakeDb = _FakeDb()
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.createFolder("")
assert result["name"] == ""
# ── Rename ────────────────────────────────────────────────────────────
def testRenameFolderHappyPath(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", name="Old Name"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.renameFolder("f-1", "New Name")
assert result["name"] == "New Name"
assert fakeDb.getRecordset(FileFolder, {"id": "f-1"})[0]["name"] == "New Name"
def testRenameFolderNotFound(self, mockRbacGet):
fakeDb = _FakeDb()
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
with pytest.raises(FileNotFoundError):
comp.renameFolder("nonexistent", "New Name")
# ── Move ──────────────────────────────────────────────────────────────
def testMoveFolderHappyPath(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", name="Movable"))
fakeDb.seed(FileFolder, _makeFolder(folderId="t-1", name="Target"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.moveFolder("f-1", "t-1")
assert result["parentId"] == "t-1"
assert fakeDb.getRecordset(FileFolder, {"id": "f-1"})[0]["parentId"] == "t-1"
def testMoveFolderToRoot(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", name="Nested", parentId="old"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.moveFolder("f-1", None)
assert result["parentId"] is None
def testMoveFolderCircularReference(self, mockRbacGet):
"""A -> B -> C: moving A under C creates a cycle."""
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="a", name="A", parentId=None))
fakeDb.seed(FileFolder, _makeFolder(folderId="b", name="B", parentId="a"))
fakeDb.seed(FileFolder, _makeFolder(folderId="c", name="C", parentId="b"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
with pytest.raises(ValueError, match="circular reference"):
comp.moveFolder("a", "c")
# ── Delete cascade ────────────────────────────────────────────────────
def testDeleteFolderCascade(self, mockRbacGet):
"""Deleting root folder removes root + child + their files."""
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="root", name="Root"))
fakeDb.seed(FileFolder, _makeFolder(folderId="child", name="Child", parentId="root"))
fakeDb.seed(FileItem, _makeFile(fileId="file-1", folderId="root"))
fakeDb.seed(FileItem, _makeFile(fileId="file-2", folderId="child"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.deleteFolderCascade("root")
assert result["deletedFolders"] == 2
assert result["deletedFiles"] == 2
def testDeleteFolderNotFound(self, mockRbacGet):
fakeDb = _FakeDb()
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
with pytest.raises(FileNotFoundError):
comp.deleteFolderCascade("nonexistent")
# ── Patch scope ───────────────────────────────────────────────────────
def testPatchScopeNoCascade(self, mockRbacGet):
"""Change folder scope without cascading to files."""
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", scope="personal"))
fakeDb.seed(FileItem, _makeFile(fileId="file-1", folderId="f-1"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.patchFolderScope("f-1", "mandate", cascadeToFiles=False)
assert result["scope"] == "mandate"
assert result["filesUpdated"] == 0
assert fakeDb.getRecordset(FileFolder, {"id": "f-1"})[0]["scope"] == "mandate"
assert fakeDb.getRecordset(FileItem, {"id": "file-1"})[0]["scope"] == "personal"
def testPatchScopeWithCascade(self, mockRbacGet):
"""cascadeToFiles=True updates only owned files in the folder."""
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", scope="personal"))
fakeDb.seed(FileItem, _makeFile(fileId="own-file", folderId="f-1"))
fakeDb.seed(FileItem, _makeFile(fileId="other-file", folderId="f-1", userId="user-b"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.patchFolderScope("f-1", "mandate", cascadeToFiles=True)
assert result["filesUpdated"] == 1
assert fakeDb.getRecordset(FileItem, {"id": "own-file"})[0]["scope"] == "mandate"
assert fakeDb.getRecordset(FileItem, {"id": "other-file"})[0]["scope"] == "personal"
def testPatchScopeInvalid(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
with pytest.raises(ValueError, match="Invalid scope"):
comp.patchFolderScope("f-1", "invalid_scope")
# ── Patch neutralize ──────────────────────────────────────────────────
def testPatchNeutralizeToggle(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="f-1", neutralize=False))
fakeDb.seed(FileItem, _makeFile(fileId="file-1", folderId="f-1"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
resultOn = comp.patchFolderNeutralize("f-1", True)
assert resultOn["neutralize"] is True
assert resultOn["filesUpdated"] == 1
assert fakeDb.getRecordset(FileFolder, {"id": "f-1"})[0]["neutralize"] is True
assert fakeDb.getRecordset(FileItem, {"id": "file-1"})[0]["neutralize"] is True
resultOff = comp.patchFolderNeutralize("f-1", False)
assert resultOff["neutralize"] is False
assert fakeDb.getRecordset(FileItem, {"id": "file-1"})[0]["neutralize"] is False
# ── Tree queries ──────────────────────────────────────────────────────
def testGetOwnFolderTree(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="own-1", name="Mine"))
fakeDb.seed(FileFolder, _makeFolder(folderId="other-1", name="Theirs", userId="user-b"))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.getOwnFolderTree()
assert len(result) == 1
assert result[0]["id"] == "own-1"
def testGetSharedFolderTreeWithContextOrphan(self, mockRbacGet):
fakeDb = _FakeDb()
fakeDb.seed(FileFolder, _makeFolder(folderId="own", name="Own"))
fakeDb.seed(FileFolder, _makeFolder(
folderId="shared-root", name="Shared Root", userId="user-b", scope="mandate",
))
fakeDb.seed(FileFolder, _makeFolder(
folderId="shared-child", name="Shared Child", userId="user-b",
parentId="shared-root", scope="mandate",
))
fakeDb.seed(FileFolder, _makeFolder(
folderId="orphan", name="Orphan", userId="user-b",
parentId="invisible-parent", scope="mandate",
))
comp = _buildComponent(fakeDb=fakeDb)
mockRbacGet.side_effect = _rbacFromFakeDb(fakeDb)
result = comp.getSharedFolderTree()
ids = {r["id"] for r in result}
assert "own" not in ids
assert "shared-root" in ids
assert "shared-child" in ids
assert "orphan" in ids
byId = {r["id"]: r for r in result}
assert byId["shared-root"]["contextOrphan"] is False
assert byId["shared-child"]["contextOrphan"] is False
assert byId["orphan"]["contextOrphan"] is True