392 lines
15 KiB
Python
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
|