221 lines
8.9 KiB
Python
221 lines
8.9 KiB
Python
"""Tests for FileManager (backend/managers/file_manager.py)."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from backend.managers.file_manager import FileManager
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_streamlit():
|
|
"""Suppress all st.error / st.warning calls — they require a running Streamlit app."""
|
|
with patch("backend.managers.file_manager.st"):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def fm(tmp_path):
|
|
"""Return a FileManager whose workspace is an isolated pytest temp directory."""
|
|
return FileManager(base_path=tmp_path)
|
|
|
|
|
|
# ── create_folder ─────────────────────────────────────────────────────────────
|
|
|
|
class TestCreateFolder:
|
|
"""Tests for create_folder(): name validation, path-traversal protection, and nested creation."""
|
|
|
|
def test_creates_folder_successfully(self, fm, tmp_path):
|
|
result = fm.create_folder("", "myfolder")
|
|
assert result is True
|
|
assert (tmp_path / "myfolder").is_dir()
|
|
|
|
def test_empty_name_returns_false(self, fm):
|
|
assert fm.create_folder("", "") is False
|
|
|
|
def test_slash_in_name_returns_false(self, fm):
|
|
assert fm.create_folder("", "a/b") is False
|
|
|
|
def test_backslash_in_name_returns_false(self, fm):
|
|
assert fm.create_folder("", "a\\b") is False
|
|
|
|
def test_duplicate_folder_returns_false(self, fm, tmp_path):
|
|
(tmp_path / "existing").mkdir()
|
|
assert fm.create_folder("", "existing") is False
|
|
|
|
def test_path_traversal_returns_false(self, fm):
|
|
assert fm.create_folder("../../", "evil") is False
|
|
|
|
def test_nested_folder_created_inside_base(self, fm, tmp_path):
|
|
(tmp_path / "sub").mkdir()
|
|
result = fm.create_folder("sub", "child")
|
|
assert result is True
|
|
assert (tmp_path / "sub" / "child").is_dir()
|
|
|
|
|
|
# ── create_file ───────────────────────────────────────────────────────────────
|
|
|
|
class TestCreateFile:
|
|
"""Tests for create_file(): name validation, auto .txt extension, and path-traversal protection."""
|
|
|
|
def test_creates_file_successfully(self, fm, tmp_path):
|
|
result = fm.create_file("", "test.py")
|
|
assert result is True
|
|
assert (tmp_path / "test.py").is_file()
|
|
|
|
def test_empty_name_returns_false(self, fm):
|
|
assert fm.create_file("", "") is False
|
|
|
|
def test_whitespace_only_name_returns_false(self, fm):
|
|
assert fm.create_file("", " ") is False
|
|
|
|
def test_no_extension_defaults_to_txt(self, fm, tmp_path):
|
|
fm.create_file("", "notes")
|
|
assert (tmp_path / "notes.txt").is_file()
|
|
|
|
def test_duplicate_file_returns_false(self, fm, tmp_path):
|
|
(tmp_path / "existing.py").touch()
|
|
assert fm.create_file("", "existing.py") is False
|
|
|
|
def test_path_traversal_returns_false(self, fm):
|
|
assert fm.create_file("../../", "evil.py") is False
|
|
|
|
|
|
# ── read_file ─────────────────────────────────────────────────────────────────
|
|
|
|
class TestReadFile:
|
|
"""Tests for read_file(): accepts an absolute Path, validates workspace boundary, returns content or ""."""
|
|
|
|
def test_reads_file_content(self, fm, tmp_path):
|
|
f = tmp_path / "hello.py"
|
|
f.write_text("print('hello')")
|
|
assert fm.read_file(f) == "print('hello')"
|
|
|
|
def test_nonexistent_file_returns_empty_string(self, fm, tmp_path):
|
|
assert fm.read_file(tmp_path / "ghost.py") == ""
|
|
|
|
def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path):
|
|
outside = tmp_path.parent / "outside.py"
|
|
outside.write_text("secret")
|
|
assert fm.read_file(outside) == ""
|
|
|
|
|
|
# ── save_file ─────────────────────────────────────────────────────────────────
|
|
|
|
class TestSaveFile:
|
|
"""Tests for save_file(): accepts an absolute path string, overwrites content, and blocks path traversal."""
|
|
|
|
def test_saves_content_to_file(self, fm, tmp_path):
|
|
f = tmp_path / "output.py"
|
|
f.touch()
|
|
result = fm.save_file(str(f), "x = 1")
|
|
assert result is True
|
|
assert f.read_text() == "x = 1"
|
|
|
|
def test_overwrites_existing_content(self, fm, tmp_path):
|
|
f = tmp_path / "script.py"
|
|
f.write_text("old content")
|
|
fm.save_file(str(f), "new content")
|
|
assert f.read_text() == "new content"
|
|
|
|
def test_path_traversal_returns_false(self, fm, tmp_path):
|
|
outside = str(tmp_path.parent / "evil.py")
|
|
assert fm.save_file(outside, "bad") is False
|
|
|
|
|
|
# ── rename_file ───────────────────────────────────────────────────────────────
|
|
|
|
class TestRenameFile:
|
|
"""Tests for rename_file(): renames by stem only — the original extension is always preserved."""
|
|
|
|
def test_renames_file_successfully(self, fm, tmp_path):
|
|
(tmp_path / "old.py").touch()
|
|
result = fm.rename_file("old.py", "new")
|
|
assert result is True
|
|
assert (tmp_path / "new.py").exists()
|
|
assert not (tmp_path / "old.py").exists()
|
|
|
|
def test_preserves_original_extension(self, fm, tmp_path):
|
|
# Even if the caller passes a different extension (.txt), rename_file
|
|
# silently replaces it with the original (.py) to prevent accidental type changes.
|
|
(tmp_path / "script.py").touch()
|
|
fm.rename_file("script.py", "renamed.txt")
|
|
assert (tmp_path / "renamed.py").exists()
|
|
|
|
def test_empty_new_name_returns_false(self, fm, tmp_path):
|
|
(tmp_path / "file.py").touch()
|
|
assert fm.rename_file("file.py", "") is False
|
|
|
|
def test_nonexistent_file_returns_false(self, fm):
|
|
assert fm.rename_file("ghost.py", "new_name") is False
|
|
|
|
def test_path_traversal_returns_false(self, fm):
|
|
assert fm.rename_file("../../evil.py", "new_name") is False
|
|
|
|
|
|
# ── delete_file ───────────────────────────────────────────────────────────────
|
|
|
|
class TestDeleteFile:
|
|
"""Tests for delete_file(): accepts a relative path, validates workspace boundary, removes the file."""
|
|
|
|
def test_deletes_file_successfully(self, fm, tmp_path):
|
|
f = tmp_path / "todelete.py"
|
|
f.touch()
|
|
result = fm.delete_file("todelete.py")
|
|
assert result is True
|
|
assert not f.exists()
|
|
|
|
def test_nonexistent_file_returns_false(self, fm):
|
|
assert fm.delete_file("ghost.py") is False
|
|
|
|
def test_path_traversal_returns_false(self, fm):
|
|
assert fm.delete_file("../../evil.py") is False
|
|
|
|
|
|
# ── delete_folder ─────────────────────────────────────────────────────────────
|
|
|
|
class TestDeleteFolder:
|
|
"""Tests for delete_folder(): recursively removes a folder and all its contents."""
|
|
|
|
def test_deletes_folder_and_contents(self, fm, tmp_path):
|
|
sub = tmp_path / "todelete"
|
|
sub.mkdir()
|
|
(sub / "file.py").touch()
|
|
result = fm.delete_folder("todelete")
|
|
assert result is True
|
|
assert not sub.exists()
|
|
|
|
def test_path_traversal_returns_false(self, fm):
|
|
assert fm.delete_folder("../../") is False
|
|
|
|
|
|
# ── get_file_tree ─────────────────────────────────────────────────────────────
|
|
|
|
class TestGetFileTree:
|
|
"""Tests for get_file_tree(): returns a nested dict where files map to None and dirs map to dicts."""
|
|
|
|
def test_empty_workspace_returns_empty_dict(self, fm):
|
|
assert fm.get_file_tree() == {}
|
|
|
|
def test_file_is_represented_as_none(self, fm, tmp_path):
|
|
(tmp_path / "main.py").touch()
|
|
tree = fm.get_file_tree()
|
|
assert tree["main.py"] is None
|
|
|
|
def test_directory_is_represented_as_dict(self, fm, tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
tree = fm.get_file_tree()
|
|
assert isinstance(tree["src"], dict)
|
|
|
|
def test_nested_structure_is_correct(self, fm, tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "app.py").touch()
|
|
tree = fm.get_file_tree()
|
|
assert tree["src"]["app.py"] is None
|
|
|