Refactor drag handling and update tests

- Renamed `initiate_drag` to `handle_drag` in MainWindow and updated related tests.
- Improved drag handling logic to utilize a bridge for starting file drags.
- Updated `_on_drag_started` and `_on_drag_failed` methods to match new signatures.
- Modified test cases to reflect changes in drag handling and assertions.

Enhance path validation and logging

- Updated `PathValidator` to log warnings for nonexistent roots instead of raising errors.
- Adjusted tests to verify the new behavior of skipping nonexistent roots.

Update web application UI and functionality

- Changed displayed text for drag items to reflect local paths and Azure Blob Storage URLs.
- Added debug logging for drag operations in the web application.
- Improved instructions for testing drag and drop functionality.

Add configuration documentation and example files

- Created `CONFIG_README.md` to provide detailed configuration instructions for WebDrop Bridge.
- Added `config.example.json` and `config_test.json` for reference and testing purposes.

Implement URL conversion logic

- Introduced `URLConverter` class to handle conversion of Azure Blob Storage URLs to local paths.
- Added unit tests for URL conversion to ensure correct functionality.

Develop download interceptor script

- Created `download_interceptor.js` to intercept download-related actions in the web application.
- Implemented logging for fetch calls, XMLHttpRequests, and Blob URL creations.

Add download test page and related tests

- Created `test_download.html` for testing various download scenarios.
- Implemented `test_download.py` to verify download path resolution and file construction.
- Added `test_url_mappings.py` to ensure URL mappings are loaded correctly.

Add unit tests for URL converter

- Created `test_url_converter.py` to validate URL conversion logic and mapping behavior.
This commit is contained in:
claudi 2026-02-17 15:56:53 +01:00
parent c9704efc8d
commit 88dc358894
21 changed files with 1870 additions and 432 deletions

View file

@ -98,12 +98,13 @@ class TestConfigFromEnv:
Config.from_env(str(env_file))
def test_from_env_invalid_root_path(self, tmp_path):
"""Test that non-existent root paths raise ConfigurationError."""
"""Test that non-existent root paths are logged as warning but don't raise error."""
env_file = tmp_path / ".env"
env_file.write_text("ALLOWED_ROOTS=/nonexistent/path/that/does/not/exist\n")
with pytest.raises(ConfigurationError, match="does not exist"):
Config.from_env(str(env_file))
# Should not raise - just logs warning and returns empty allowed_roots
config = Config.from_env(str(env_file))
assert config.allowed_roots == [] # Non-existent roots are skipped
def test_from_env_empty_webapp_url(self, tmp_path):
"""Test that empty webapp URL raises ConfigurationError."""

View file

@ -3,63 +3,79 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from webdrop_bridge.config import Config
from webdrop_bridge.core.drag_interceptor import DragInterceptor
from webdrop_bridge.core.validator import PathValidator
@pytest.fixture
def test_config(tmp_path):
"""Create test configuration."""
return Config(
app_name="Test App",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://wps.agravity.io/",
url_mappings=[],
check_file_exists=True,
)
class TestDragInterceptorInitialization:
"""Test DragInterceptor initialization and setup."""
def test_drag_interceptor_creation(self, qtbot):
def test_drag_interceptor_creation(self, qtbot, test_config):
"""Test DragInterceptor can be instantiated."""
interceptor = DragInterceptor()
interceptor = DragInterceptor(test_config)
assert interceptor is not None
assert interceptor._validator is None
assert interceptor._validator is not None
assert interceptor._url_converter is not None
def test_drag_interceptor_has_signals(self, qtbot):
def test_drag_interceptor_has_signals(self, qtbot, test_config):
"""Test DragInterceptor has required signals."""
interceptor = DragInterceptor()
interceptor = DragInterceptor(test_config)
assert hasattr(interceptor, "drag_started")
assert hasattr(interceptor, "drag_failed")
def test_set_validator(self, qtbot, tmp_path):
"""Test setting validator on drag interceptor."""
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
assert interceptor._validator is validator
def test_set_validator(self, qtbot, test_config):
"""Test validator is set during construction."""
interceptor = DragInterceptor(test_config)
assert interceptor._validator is not None
class TestDragInterceptorValidation:
"""Test path validation in drag operations."""
def test_initiate_drag_no_files(self, qtbot):
"""Test initiating drag with no files fails."""
interceptor = DragInterceptor()
def test_handle_drag_empty_text(self, qtbot, test_config):
"""Test handling drag with empty text fails."""
interceptor = DragInterceptor(test_config)
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.initiate_drag([])
result = interceptor.handle_drag("")
assert result is False
def test_initiate_drag_no_validator(self, qtbot):
"""Test initiating drag without validator fails."""
interceptor = DragInterceptor()
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.initiate_drag(["file.txt"])
assert result is False
def test_initiate_drag_single_valid_file(self, qtbot, tmp_path):
"""Test initiating drag with single valid file."""
def test_handle_drag_valid_file_path(self, qtbot, tmp_path):
"""Test handling drag with valid file path."""
# Create a test file
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
# Mock the drag operation to simulate success
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
@ -69,114 +85,91 @@ class TestDragInterceptorValidation:
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.initiate_drag([str(test_file)])
result = interceptor.handle_drag(str(test_file))
# Should return True on successful drag
assert result is True
def test_initiate_drag_invalid_path(self, qtbot, tmp_path):
def test_handle_drag_invalid_path(self, qtbot, test_config):
"""Test drag with invalid path fails."""
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
interceptor = DragInterceptor(test_config)
# Path outside allowed roots
invalid_path = Path("/etc/passwd")
invalid_path = "/etc/passwd"
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.initiate_drag([str(invalid_path)])
result = interceptor.handle_drag(invalid_path)
assert result is False
def test_initiate_drag_nonexistent_file(self, qtbot, tmp_path):
def test_handle_drag_nonexistent_file(self, qtbot, test_config, tmp_path):
"""Test drag with nonexistent file fails."""
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
interceptor = DragInterceptor(test_config)
nonexistent = tmp_path / "nonexistent.txt"
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.initiate_drag([str(nonexistent)])
result = interceptor.handle_drag(str(nonexistent))
assert result is False
class TestDragInterceptorMultipleFiles:
"""Test drag operations with multiple files."""
class TestDragInterceptorAzureURL:
"""Test Azure URL to local path conversion in drag operations."""
def test_initiate_drag_multiple_files(self, qtbot, tmp_path):
"""Test drag with multiple valid files."""
# Create test files
file1 = tmp_path / "file1.txt"
file2 = tmp_path / "file2.txt"
file1.write_text("content 1")
file2.write_text("content 2")
def test_handle_drag_azure_url(self, qtbot, tmp_path):
"""Test handling drag with Azure Blob Storage URL."""
from webdrop_bridge.config import URLMapping
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
# Create test file that would be the result
test_file = tmp_path / "test.png"
test_file.write_text("image data")
from PySide6.QtCore import Qt
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[
URLMapping(
url_prefix="https://wpsagravitystg.file.core.windows.net/wpsagravitysync/",
local_path=str(tmp_path)
)
],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
# Azure URL
azure_url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/test.png"
# Mock the drag operation
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.initiate_drag([str(file1), str(file2)])
result = interceptor.handle_drag(azure_url)
assert result is True
def test_initiate_drag_mixed_valid_invalid(self, qtbot, tmp_path):
"""Test drag with mix of valid and invalid paths fails."""
test_file = tmp_path / "valid.txt"
test_file.write_text("content")
def test_handle_drag_unmapped_url(self, qtbot, test_config):
"""Test handling drag with unmapped URL fails."""
interceptor = DragInterceptor(test_config)
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
# URL with no mapping
unmapped_url = "https://unknown.blob.core.windows.net/container/file.png"
# Mix of valid and invalid paths
with qtbot.waitSignal(interceptor.drag_failed):
result = interceptor.initiate_drag(
[str(test_file), "/etc/passwd"]
)
result = interceptor.handle_drag(unmapped_url)
assert result is False
class TestDragInterceptorMimeData:
"""Test MIME data creation and file URL formatting."""
def test_mime_data_creation(self, qtbot, tmp_path):
"""Test MIME data is created with proper file URLs."""
test_file = tmp_path / "test.txt"
test_file.write_text("test")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
interceptor.initiate_drag([str(test_file)])
# Check MIME data was set correctly
call_args = mock_drag_instance.setMimeData.call_args
mime_data = call_args[0][0]
# Verify URLs were set
urls = mime_data.urls()
assert len(urls) == 1
# Check that the URL contains file:// scheme (can be string repr or QUrl)
url_str = str(urls[0]).lower()
assert "file://" in url_str
class TestDragInterceptorSignals:
"""Test signal emission on drag operations."""
@ -185,153 +178,60 @@ class TestDragInterceptorSignals:
test_file = tmp_path / "test.txt"
test_file.write_text("content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
from PySide6.QtCore import Qt
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[],
webapp_url="https://test.com/",
url_mappings=[],
check_file_exists=True,
)
interceptor = DragInterceptor(config)
# Connect to signal manually
signal_spy = []
interceptor.drag_started.connect(lambda paths: signal_spy.append(paths))
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.initiate_drag([str(test_file)])
result = interceptor.handle_drag(str(test_file))
# Verify result and signal emission
assert result is True
assert len(signal_spy) == 1
def test_drag_failed_signal_on_no_files(self, qtbot):
"""Test drag_failed signal on empty file list."""
interceptor = DragInterceptor()
def test_drag_failed_signal_on_empty_text(self, qtbot, test_config):
"""Test drag_failed signal on empty text."""
interceptor = DragInterceptor(test_config)
# Connect to signal manually
signal_spy = []
interceptor.drag_failed.connect(lambda msg: signal_spy.append(msg))
interceptor.drag_failed.connect(lambda src, msg: signal_spy.append((src, msg)))
result = interceptor.initiate_drag([])
result = interceptor.handle_drag("")
# Verify result and signal emission
assert result is False
assert len(signal_spy) == 1
assert "No files" in signal_spy[0]
assert "Empty" in signal_spy[0][1]
def test_drag_failed_signal_on_validation_error(self, qtbot, tmp_path):
def test_drag_failed_signal_on_validation_error(self, qtbot, test_config):
"""Test drag_failed signal on validation failure."""
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
interceptor = DragInterceptor(test_config)
# Connect to signal manually
signal_spy = []
interceptor.drag_failed.connect(lambda msg: signal_spy.append(msg))
interceptor.drag_failed.connect(lambda src, msg: signal_spy.append((src, msg)))
result = interceptor.initiate_drag(["/invalid/path/file.txt"])
result = interceptor.handle_drag("/invalid/path/file.txt")
# Verify result and signal emission
assert result is False
assert len(signal_spy) == 1
class TestDragInterceptorDragExecution:
"""Test drag operation execution and result handling."""
def test_drag_cancelled_returns_false(self, qtbot, tmp_path):
"""Test drag cancellation returns False."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = 0 # Cancelled/failed
mock_drag.return_value = mock_drag_instance
# Connect to signal manually
signal_spy = []
interceptor.drag_failed.connect(lambda msg: signal_spy.append(msg))
result = interceptor.initiate_drag([str(test_file)])
assert result is False
assert len(signal_spy) == 1
def test_pixmap_created_from_widget(self, qtbot, tmp_path):
"""Test pixmap is created from widget grab."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
from PySide6.QtCore import Qt
with patch.object(interceptor, "grab") as mock_grab:
mock_pixmap = MagicMock()
mock_grab.return_value.scaled.return_value = mock_pixmap
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
interceptor.initiate_drag([str(test_file)])
# Verify grab was called and pixmap was set
mock_grab.assert_called_once()
mock_drag_instance.setPixmap.assert_called_once_with(mock_pixmap)
class TestDragInterceptorIntegration:
"""Integration tests with PathValidator."""
def test_drag_with_nested_file(self, qtbot, tmp_path):
"""Test drag with file in nested directory."""
nested_dir = tmp_path / "nested" / "dir"
nested_dir.mkdir(parents=True)
test_file = nested_dir / "file.txt"
test_file.write_text("nested content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
result = interceptor.initiate_drag([str(test_file)])
assert result is True
def test_drag_with_relative_path(self, qtbot, tmp_path):
"""Test drag with relative path resolution."""
test_file = tmp_path / "relative.txt"
test_file.write_text("content")
interceptor = DragInterceptor()
validator = PathValidator([tmp_path])
interceptor.set_validator(validator)
# This would work if run from the directory, but we'll just verify
# the interceptor handles Path objects correctly
from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
# Direct absolute path for reliable test
result = interceptor.initiate_drag([str(test_file)])
assert result is True

View file

@ -231,10 +231,12 @@ class TestMainWindowDragIntegration:
assert window.drag_interceptor.drag_started is not None
assert window.drag_interceptor.drag_failed is not None
def test_initiate_drag_delegates_to_interceptor(
def test_handle_drag_delegates_to_interceptor(
self, qtbot, sample_config, tmp_path
):
"""Test initiate_drag method delegates to interceptor."""
"""Test drag handling delegates to interceptor."""
from PySide6.QtCore import QCoreApplication
window = MainWindow(sample_config)
qtbot.addWidget(window)
@ -243,29 +245,32 @@ class TestMainWindowDragIntegration:
test_file.write_text("test")
with patch.object(
window.drag_interceptor, "initiate_drag"
window.drag_interceptor, "handle_drag"
) as mock_drag:
mock_drag.return_value = True
result = window.initiate_drag([str(test_file)])
# Call through bridge
window._drag_bridge.start_file_drag(str(test_file))
# Process deferred QTimer.singleShot(0, ...) call
QCoreApplication.processEvents()
mock_drag.assert_called_once_with([str(test_file)])
assert result is True
mock_drag.assert_called_once_with(str(test_file))
def test_on_drag_started_called(self, qtbot, sample_config):
"""Test _on_drag_started handler can be called."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Should not raise
window._on_drag_started(["/some/path"])
# Should not raise - new signature has source and local_path
window._on_drag_started("https://example.com/file.png", "/local/path/file.png")
def test_on_drag_failed_called(self, qtbot, sample_config):
"""Test _on_drag_failed handler can be called."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Should not raise
window._on_drag_failed("Test error message")
# Should not raise - new signature has source and error
window._on_drag_failed("https://example.com/file.png", "Test error message")
class TestMainWindowURLWhitelist:

View file

@ -0,0 +1,144 @@
"""Unit tests for URL converter."""
from pathlib import Path
import pytest
from webdrop_bridge.config import Config, URLMapping
from webdrop_bridge.core.url_converter import URLConverter
@pytest.fixture
def test_config():
"""Create test configuration with URL mappings."""
return Config(
app_name="Test App",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[],
allowed_urls=[],
webapp_url="https://wps.agravity.io/",
url_mappings=[
URLMapping(
url_prefix="https://wpsagravitystg.file.core.windows.net/wpsagravitysync/",
local_path="Z:"
),
URLMapping(
url_prefix="https://other.blob.core.windows.net/container/",
local_path="Y:\\shared"
)
]
)
@pytest.fixture
def converter(test_config):
"""Create URL converter with test config."""
return URLConverter(test_config)
def test_convert_simple_url(converter):
"""Test converting a simple Azure URL to local path."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/test/file.png"
result = converter.convert_url_to_path(url)
assert result is not None
assert str(result).endswith("test\\file.png") # Windows path separator
def test_convert_url_with_special_characters(converter):
"""Test URL with special characters (URL encoded)."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/folder/file%20with%20spaces.png"
result = converter.convert_url_to_path(url)
assert result is not None
assert "file with spaces.png" in str(result)
def test_convert_url_with_subdirectories(converter):
"""Test URL with deep directory structure."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/subfolder/file.png"
result = converter.convert_url_to_path(url)
assert result is not None
assert "aN5PysnXIuRECzcRbvHkjL7g0" in str(result)
assert "subfolder" in str(result)
def test_convert_unmapped_url(converter):
"""Test URL that doesn't match any mapping."""
url = "https://unknown.blob.core.windows.net/container/file.png"
result = converter.convert_url_to_path(url)
assert result is None
def test_convert_empty_url(converter):
"""Test empty URL."""
result = converter.convert_url_to_path("")
assert result is None
def test_convert_none_url(converter):
"""Test None URL."""
result = converter.convert_url_to_path(None)
assert result is None
def test_is_azure_url_positive(converter):
"""Test recognizing valid Azure URLs."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/file.png"
assert converter.is_azure_url(url) is True
def test_is_azure_url_negative(converter):
"""Test rejecting non-Azure URLs."""
assert converter.is_azure_url("https://example.com/file.png") is False
assert converter.is_azure_url("Z:\\file.png") is False
assert converter.is_azure_url("") is False
def test_multiple_mappings(converter):
"""Test that correct mapping is used for URL."""
url1 = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/file.png"
url2 = "https://other.blob.core.windows.net/container/file.png"
result1 = converter.convert_url_to_path(url1)
result2 = converter.convert_url_to_path(url2)
assert result1 is not None
assert result2 is not None
assert str(result1).startswith("Z:")
assert str(result2).startswith("Y:")
def test_url_mapping_validation_http():
"""Test that URL mapping requires http:// or https://."""
with pytest.raises(Exception): # ConfigurationError
URLMapping(
url_prefix="ftp://server/path/",
local_path="Z:"
)
def test_url_mapping_adds_trailing_slash():
"""Test that URL mapping adds trailing slash if missing."""
mapping = URLMapping(
url_prefix="https://example.com/path",
local_path="Z:"
)
assert mapping.url_prefix.endswith("/")
def test_convert_url_example_from_docs(converter):
"""Test the exact example from documentation."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png"
result = converter.convert_url_to_path(url)
assert result is not None
# Should be: Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png
expected_parts = ["Z:", "aN5PysnXIuRECzcRbvHkjL7g0", "Hintergrund_Agravity.png"]
result_str = str(result)
for part in expected_parts:
assert part in result_str

View file

@ -22,11 +22,12 @@ class TestPathValidator:
assert len(validator.allowed_roots) == 2
def test_validator_nonexistent_root(self, tmp_path):
"""Test that nonexistent root raises ValidationError."""
"""Test that nonexistent root is logged as warning but doesn't raise error."""
nonexistent = tmp_path / "nonexistent"
with pytest.raises(ValidationError, match="does not exist"):
PathValidator([nonexistent])
# Should not raise - just logs warning and skips the root
validator = PathValidator([nonexistent])
assert len(validator.allowed_roots) == 0 # Non-existent roots are skipped
def test_validator_non_directory_root(self, tmp_path):
"""Test that non-directory root raises ValidationError."""