# #region Test.StoragePlugin [C:3] [TYPE Module] [SEMANTICS test, storage, plugin, filesystem] # @BRIEF Unit tests for StoragePlugin — properties, path resolution, file operations, and edge cases. # @RELATION BINDS_TO -> [StoragePlugin] # @TEST_EDGE: path_traversal -> validate_path raises ValueError # @TEST_EDGE: missing_variable -> resolve_path falls back to stripped literal # @TEST_EDGE: category_mismatch -> delete_file/get_file_path raise ValueError # @TEST_EDGE: file_not_found -> delete_file/get_file_path raise FileNotFoundError # @TEST_EDGE: missing_category -> list_files root view returns category directories from __future__ import annotations from datetime import datetime from pathlib import Path import tempfile from typing import Any from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import pytest from src.plugins.storage.plugin import StoragePlugin, _copy_upload_to_disk from src.models.storage import FileCategory, StoredFile # ── Helpers ── def _mock_config_manager(storage_root: str | None = None): """Create a mock ConfigManager with storage root settings.""" cm = MagicMock() cfg = MagicMock() if storage_root: cfg.settings.storage.root_path = storage_root else: cfg.settings.storage.root_path = "/app/storage" cm.get_config.return_value = cfg return cm def _mock_task_context(): ctx = MagicMock() ctx.logger = MagicMock() ctx.logger.with_source.return_value = MagicMock() return ctx class TestStoragePluginProperties: """Verify static property values.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_id(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() assert plugin.id == "storage-manager" @patch("src.plugins.storage.plugin.get_config_manager") def test_name(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() assert plugin.name == "Storage Manager" @patch("src.plugins.storage.plugin.get_config_manager") def test_description(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() assert plugin.description == "Manages local file storage for backups and repositories." @patch("src.plugins.storage.plugin.get_config_manager") def test_version(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() assert plugin.version == "1.0.0" @patch("src.plugins.storage.plugin.get_config_manager") def test_ui_route(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() assert plugin.ui_route == "/tools/storage" class TestStoragePluginGetSchema: """Verify get_schema output.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_get_schema(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() schema = plugin.get_schema() assert schema["type"] == "object" assert "category" in schema["properties"] assert schema["properties"]["category"]["type"] == "string" assert schema["properties"]["category"]["enum"] == [c.value for c in FileCategory] assert schema["required"] == ["category"] class TestStoragePluginExecute: """Verify StoragePlugin.execute with various scenarios.""" @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_execute_basic(self, mock_get_cm): """Execute with basic params.""" mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() result = await plugin.execute({"category": "backups"}) assert result is None @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_execute_with_context(self, mock_get_cm): """Execute with TaskContext — uses context.logger.""" mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() ctx = _mock_task_context() result = await plugin.execute({"category": "backups"}, context=ctx) assert result is None ctx.logger.with_source.assert_called() class TestStoragePluginGetStorageRoot: """Verify get_storage_root path resolution.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_absolute_path(self, mock_get_cm): """Absolute storage root returns directly.""" mock_get_cm.return_value = _mock_config_manager(storage_root="/tmp/test_storage") plugin = StoragePlugin() root = plugin.get_storage_root() assert root == Path("/tmp/test_storage") @patch("src.plugins.storage.plugin.get_config_manager") def test_relative_path(self, mock_get_cm): """Relative storage root resolves against project root.""" mock_get_cm.return_value = _mock_config_manager(storage_root="relative/storage") plugin = StoragePlugin() root = plugin.get_storage_root() # Should resolve relative to project root (parents[3] of plugin.py) assert root.is_absolute() assert "relative/storage" in str(root) class TestStoragePluginResolvePath: """Verify resolve_path pattern substitution.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_resolve_with_variables(self, mock_get_cm): mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() result = plugin.resolve_path("backups/{name}_{timestamp}", {"name": "test"}) # {timestamp} is auto-filled, {name} comes from variables assert result.startswith("backups/test_") # Verify timestamp format (YYYYMMDDTHHMMSS) timestamp_part = result.split("_", 1)[1] assert len(timestamp_part) == 15 # YYYYMMDDTHHMMSS @patch("src.plugins.storage.plugin.get_config_manager") def test_resolve_clean_double_slashes(self, mock_get_cm): """Double slashes are normalized.""" mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() result = plugin.resolve_path("backups//{name}", {"name": "test"}) assert "//" not in result assert result == "backups/test" @patch("src.plugins.storage.plugin.get_config_manager") def test_resolve_missing_variable(self, mock_get_cm): """Missing variable falls back to stripped literal.""" mock_get_cm.return_value = _mock_config_manager() plugin = StoragePlugin() result = plugin.resolve_path("backups/{name}_{missing}", {"name": "test"}) # Fallback: strip braces from the pattern assert "{" not in result assert "}" not in result class TestStoragePluginValidatePath: """Verify validate_path prevents traversal and returns valid paths.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_valid_path(self, mock_get_cm): """Path within storage root is accepted.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) # ensure_directories creates category subdirs — don't rmdir parent plugin = StoragePlugin() valid = Path(tmpdir) / "test_file.txt" valid.touch() result = plugin.validate_path(valid) assert result == valid.resolve() @patch("src.plugins.storage.plugin.get_config_manager") def test_path_traversal_raises(self, mock_get_cm): """Path outside storage root raises ValueError.""" mock_get_cm.return_value = _mock_config_manager(storage_root="/tmp/test_storage_traversal") plugin = StoragePlugin() outside = Path("/etc/passwd") with pytest.raises(ValueError, match="Access denied"): plugin.validate_path(outside) class TestStoragePluginEnsureDirectories: """Verify ensure_directories creates category subfolders.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_ensure_directories_creates_folders(self, mock_get_cm): """Category directories are created under storage root.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) plugin = StoragePlugin() # ensure_directories called in __init__ already for cat in FileCategory: assert (Path(tmpdir) / cat.value).exists() class TestStoragePluginListFiles: """Verify list_files in various modes.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_root_view(self, mock_get_cm): """Root view (no category) returns category directories.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) # Create category directories for cat in FileCategory: (Path(tmpdir) / cat.value).mkdir(parents=True, exist_ok=True) plugin = StoragePlugin() files = plugin.list_files() assert len(files) == len(FileCategory) names = {f.name for f in files} for cat in FileCategory: assert cat.value in names # All returned as directories for f in files: assert f.mime_type == "directory" assert f.size == 0 @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_with_category(self, mock_get_cm): """Filter by category returns files in that category.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) (backup_dir / "file1.txt").write_text("hello") (backup_dir / "file2.txt").write_text("world") plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP) assert len(files) == 2 assert files[0].name == "file1.txt" assert files[1].name == "file2.txt" @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_recursive(self, mock_get_cm): """Recursive mode scans subdirectories.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" nested = backup_dir / "sub" / "dir" nested.mkdir(parents=True, exist_ok=True) (nested / "deep.txt").write_text("deep") plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP, recursive=True) assert len(files) == 1 assert files[0].name == "deep.txt" @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_missing_category_dir(self, mock_get_cm): """Missing category directory returns empty list.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) # Don't create any directories plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP) assert files == [] @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_skip_logs(self, mock_get_cm): """Files/dirs containing 'Logs' are skipped.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) (backup_dir / "good.txt").write_text("good") (backup_dir / "LogsFile.txt").write_text("logs") log_dir = backup_dir / "Logs" log_dir.mkdir() plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP) # Only good.txt should appear names = [f.name for f in files] assert "good.txt" in names assert "LogsFile.txt" not in names @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_skip_logs_recursive(self, mock_get_cm): """Recursive scan skips files with 'Logs' in their filename (L284).""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" nested = backup_dir / "daily" nested.mkdir(parents=True, exist_ok=True) (nested / "report.txt").write_text("clean") (nested / "TransactionLogs.csv").write_text("log data") plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP, recursive=True) assert len(files) == 1 assert files[0].name == "report.txt" @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_recursive_missing_dir(self, mock_get_cm): """Recursive mode with missing category dir continues silently (L274).""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) # Patch ensure_directories so category dirs are NOT created with patch.object(StoragePlugin, "ensure_directories"): plugin = StoragePlugin() files = plugin.list_files(category=FileCategory.BACKUP, recursive=True) assert files == [] @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_root_view_missing_categories(self, mock_get_cm): """Root view listing skips missing category dirs via continue (L249).""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) # Patch ensure_directories so only ONE category exists on disk with patch.object(StoragePlugin, "ensure_directories"): plugin = StoragePlugin() # Manually create only one category dir (Path(tmpdir) / "backups").mkdir() files = plugin.list_files() # Only "backups" should appear; "repositories" is missing → hits L249 continue assert len(files) == 1 assert files[0].name == "backups" @patch("src.plugins.storage.plugin.get_config_manager") def test_list_files_with_subpath(self, mock_get_cm): """Subpath within category narrows listing.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) nested = Path(tmpdir) / "backups" / "monthly" / "week1" nested.mkdir(parents=True, exist_ok=True) (nested / "report.txt").write_text("report") plugin = StoragePlugin() files = plugin.list_files( category=FileCategory.BACKUP, subpath="monthly/week1", ) assert len(files) == 1 assert files[0].name == "report.txt" class TestStoragePluginSaveFile: """Verify save_file writes uploaded content to disk.""" @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_save_file_without_subpath(self, mock_get_cm): """File saved directly under category directory.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) upload_file = MagicMock() upload_file.filename = "test_doc.pdf" upload_file.content_type = "application/pdf" mock_file_obj = MagicMock() mock_file_obj.read.return_value = b"" upload_file.file = mock_file_obj plugin = StoragePlugin() result = await plugin.save_file( file=upload_file, category=FileCategory.BACKUP, ) assert result.name == "test_doc.pdf" assert result.category == FileCategory.BACKUP assert result.mime_type == "application/pdf" assert result.path.startswith("backups/") assert isinstance(result, StoredFile) @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_save_file_with_subpath(self, mock_get_cm): """File saved under category/subpath.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) upload_file = MagicMock() upload_file.filename = "report.xlsx" upload_file.content_type = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) mock_file_obj = MagicMock() mock_file_obj.read.return_value = b"" upload_file.file = mock_file_obj plugin = StoragePlugin() result = await plugin.save_file( file=upload_file, category=FileCategory.REPOSITORY, subpath="archived/2024", ) assert result.name == "report.xlsx" assert result.category == FileCategory.REPOSITORY assert "archived/2024" in result.path class TestStoragePluginDeleteFile: """Verify delete_file removes files and directories.""" @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_delete_file_success(self, mock_get_cm): """Existing file is deleted.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) target = backup_dir / "to_delete.txt" target.write_text("bye") plugin = StoragePlugin() await plugin.delete_file(FileCategory.BACKUP, "backups/to_delete.txt") assert not target.exists() @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_delete_directory_success(self, mock_get_cm): """Existing directory is removed via shutil.rmtree.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" target_dir = backup_dir / "old_backup" target_dir.mkdir(parents=True, exist_ok=True) (target_dir / "inner.txt").write_text("inner") plugin = StoragePlugin() await plugin.delete_file(FileCategory.BACKUP, "backups/old_backup") assert not target_dir.exists() @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_delete_file_not_found(self, mock_get_cm): """Non-existent file raises FileNotFoundError.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) plugin = StoragePlugin() with pytest.raises(FileNotFoundError, match="not found"): await plugin.delete_file(FileCategory.BACKUP, "backups/ghost.txt") @patch("src.plugins.storage.plugin.get_config_manager") @pytest.mark.asyncio async def test_delete_file_category_mismatch(self, mock_get_cm): """Path not starting with category value raises ValueError.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) target = backup_dir / "file.txt" target.write_text("data") plugin = StoragePlugin() with pytest.raises(ValueError, match="does not belong to category"): await plugin.delete_file(FileCategory.BACKUP, "repositories/file.txt") class TestStoragePluginGetFilePath: """Verify get_file_path returns valid paths.""" @patch("src.plugins.storage.plugin.get_config_manager") def test_get_file_path_success(self, mock_get_cm): """Existing file returns resolved path.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) target = backup_dir / "download.txt" target.write_text("content") plugin = StoragePlugin() result = plugin.get_file_path(FileCategory.BACKUP, "backups/download.txt") assert result == target.resolve() @patch("src.plugins.storage.plugin.get_config_manager") def test_get_file_path_directory_raises(self, mock_get_cm): """Directory path raises FileNotFoundError (not a file).""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) plugin = StoragePlugin() with pytest.raises(FileNotFoundError, match="not found"): plugin.get_file_path(FileCategory.BACKUP, "backups") @patch("src.plugins.storage.plugin.get_config_manager") def test_get_file_path_not_found(self, mock_get_cm): """Non-existent file raises FileNotFoundError.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) plugin = StoragePlugin() with pytest.raises(FileNotFoundError, match="not found"): plugin.get_file_path(FileCategory.BACKUP, "backups/missing.txt") @patch("src.plugins.storage.plugin.get_config_manager") def test_get_file_path_category_mismatch(self, mock_get_cm): """Path not starting with category value raises ValueError.""" with tempfile.TemporaryDirectory() as tmpdir: mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir) backup_dir = Path(tmpdir) / "backups" backup_dir.mkdir(parents=True, exist_ok=True) plugin = StoragePlugin() with pytest.raises(ValueError, match="does not belong to category"): plugin.get_file_path(FileCategory.BACKUP, "repositories/download.txt") class TestCopyUploadToDisk: """Verify the helper function _copy_upload_to_disk.""" def test_copy_upload_to_disk(self): """File content is copied from source to destination.""" with tempfile.TemporaryDirectory() as tmpdir: dest = Path(tmpdir) / "output.bin" source = MagicMock() # Return data once, then empty bytes to signal EOF source.read = MagicMock(side_effect=[b"test data", b""]) _copy_upload_to_disk(dest, source) assert dest.read_bytes() == b"test data" def test_copy_upload_to_disk_empty(self): """Empty source file is handled.""" with tempfile.TemporaryDirectory() as tmpdir: dest = Path(tmpdir) / "empty.bin" source = MagicMock() source.read = MagicMock(return_value=b"") _copy_upload_to_disk(dest, source) assert dest.read_bytes() == b"" # #endregion Test.StoragePlugin