# 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