base
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from plane.db.models import Project, ProjectMember, Issue, FileAsset
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.copy_s3_object import (
|
||||
copy_s3_objects_of_description_and_assets,
|
||||
copy_assets,
|
||||
)
|
||||
import base64
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCopyS3Objects:
|
||||
"""Test the copy_s3_objects_of_description_and_assets function"""
|
||||
|
||||
@pytest.fixture
|
||||
def project(self, create_user, workspace):
|
||||
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user)
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def issue(self, workspace, project):
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project_id=project.id,
|
||||
description_html='<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>', # noqa: E501
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def file_asset(self, workspace, project, issue):
|
||||
return FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-1.jpg",
|
||||
attributes={
|
||||
"name": "test-asset-1.jpg",
|
||||
"size": 100,
|
||||
"type": "image/jpeg",
|
||||
},
|
||||
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_s3_objects_of_description_and_assets(
|
||||
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
|
||||
):
|
||||
FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-2.pdf",
|
||||
attributes={
|
||||
"name": "test-asset-2.pdf",
|
||||
"size": 100,
|
||||
"type": "application/pdf",
|
||||
},
|
||||
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
issue.save()
|
||||
|
||||
# Set up mock S3 storage
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Mock the external service call to avoid actual HTTP requests
|
||||
with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync:
|
||||
mock_sync.return_value = {
|
||||
"description": "test description",
|
||||
"description_binary": base64.b64encode(b"test binary").decode(),
|
||||
}
|
||||
|
||||
# Call the actual function (not .delay())
|
||||
copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id)
|
||||
|
||||
# Assert that copy_object was called for each asset
|
||||
assert mock_storage_instance.copy_object.call_count == 2
|
||||
|
||||
# Get the updated issue and its new assets
|
||||
updated_issue = Issue.objects.get(id=issue.id)
|
||||
new_assets = FileAsset.objects.filter(
|
||||
issue=updated_issue,
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
# Verify new assets were created
|
||||
assert new_assets.count() == 4 # 2 original + 2 copied
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset):
|
||||
"""Test successful copying of assets"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[file_asset.id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify S3 copy was called
|
||||
mock_storage_instance.copy_object.assert_called_once()
|
||||
|
||||
# Verify new asset was created
|
||||
assert len(result) == 1
|
||||
new_asset_id = result[0]["new_asset_id"]
|
||||
new_asset = FileAsset.objects.get(id=new_asset_id)
|
||||
|
||||
# Verify asset properties were copied correctly
|
||||
assert new_asset.workspace == workspace
|
||||
assert new_asset.project_id == project.id
|
||||
assert new_asset.entity_type == file_asset.entity_type
|
||||
assert new_asset.attributes == file_asset.attributes
|
||||
assert new_asset.size == file_asset.size
|
||||
assert new_asset.is_uploaded is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue):
|
||||
"""Test copying with empty asset_ids list"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue):
|
||||
"""Test copying with non-existent asset ID"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
non_existent_id = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[non_existent_id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
@@ -0,0 +1,126 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
|
||||
|
||||
|
||||
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
|
||||
"""Create a mock requests.Response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.is_redirect = is_redirect
|
||||
resp.headers = headers or {}
|
||||
resp.content = content
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlIp:
|
||||
"""Test validate_url_ip blocks private/internal IPs."""
|
||||
|
||||
def test_rejects_private_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_loopback(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_non_http_scheme(self):
|
||||
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
|
||||
validate_url_ip("file:///etc/passwd")
|
||||
|
||||
def test_allows_public_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
|
||||
validate_url_ip("https://example.com") # Should not raise
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafeGet:
|
||||
"""Test safe_get follows redirects safely and blocks SSRF."""
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
mock_get.return_value = final_resp
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response is final_resp
|
||||
assert final_url == "https://example.com"
|
||||
mock_validate.assert_called_once_with("https://example.com")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=301,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://other.com/page"},
|
||||
)
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
mock_get.side_effect = [redirect_resp, final_resp]
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response is final_resp
|
||||
assert final_url == "https://other.com/page"
|
||||
# Should validate both the initial URL and the redirect target
|
||||
assert mock_validate.call_count == 2
|
||||
mock_validate.assert_any_call("https://example.com")
|
||||
mock_validate.assert_any_call("https://other.com/page")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "http://192.168.1.1:8080"},
|
||||
)
|
||||
mock_get.return_value = redirect_resp
|
||||
# First call (initial URL) succeeds, second call (redirect target) fails
|
||||
mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
|
||||
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
safe_get("https://evil.com/redirect")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://example.com/loop"},
|
||||
)
|
||||
mock_get.return_value = redirect_resp
|
||||
|
||||
with pytest.raises(RuntimeError, match="Too many redirects"):
|
||||
safe_get("https://example.com/start")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
|
||||
"""After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://example.com/next"},
|
||||
)
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
# 5 redirects then a 200
|
||||
mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
|
||||
|
||||
response, final_url = safe_get("https://example.com/start")
|
||||
|
||||
assert response is final_resp
|
||||
assert not response.is_redirect
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit tests for ReadReplicaRoutingMiddleware.
|
||||
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
|
||||
that handles intelligent database routing to read replicas based on HTTP methods
|
||||
and view configuration.
|
||||
Test Organization:
|
||||
- TestReadReplicaRoutingMiddleware: Core middleware functionality
|
||||
- TestProcessView: process_view method behavior
|
||||
- TestReplicaDecisionLogic: Decision logic for replica usage
|
||||
- TestAttributeDetection: View attribute detection methods
|
||||
- TestExceptionHandling: Exception handling and cleanup
|
||||
- TestRealViewIntegration: Real Django/DRF view integration
|
||||
- TestEdgeCases: Edge cases and error conditions
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.views import View
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from plane.middleware.db_routing import ReadReplicaRoutingMiddleware
|
||||
|
||||
|
||||
# Pytest fixtures
|
||||
@pytest.fixture
|
||||
def mock_get_response():
|
||||
"""Fixture for mocked get_response callable."""
|
||||
return Mock(return_value=HttpResponse())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware(mock_get_response):
|
||||
"""Fixture for ReadReplicaRoutingMiddleware instance."""
|
||||
return ReadReplicaRoutingMiddleware(mock_get_response)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory():
|
||||
"""Fixture for Django RequestFactory."""
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_view_func():
|
||||
"""Fixture for a basic mocked view function."""
|
||||
view = Mock()
|
||||
view.use_read_replica = True
|
||||
return view
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_request(request_factory):
|
||||
"""Fixture for a GET request."""
|
||||
return request_factory.get("/api/test/")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_request(request_factory):
|
||||
"""Fixture for a POST request."""
|
||||
return request_factory.post("/api/test/")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadReplicaRoutingMiddleware:
|
||||
"""Test cases for ReadReplicaRoutingMiddleware core functionality."""
|
||||
|
||||
def test_middleware_initialization(self, middleware, mock_get_response):
|
||||
"""Test middleware initializes correctly with expected attributes."""
|
||||
assert middleware.get_response == mock_get_response
|
||||
assert hasattr(middleware, "READ_ONLY_METHODS")
|
||||
assert "GET" in middleware.READ_ONLY_METHODS
|
||||
assert "HEAD" in middleware.READ_ONLY_METHODS
|
||||
assert "OPTIONS" in middleware.READ_ONLY_METHODS
|
||||
|
||||
def test_read_only_methods_constant(self, middleware):
|
||||
"""Test READ_ONLY_METHODS contains expected HTTP methods."""
|
||||
expected_methods = {"GET", "HEAD", "OPTIONS"}
|
||||
assert middleware.READ_ONLY_METHODS == expected_methods
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_routes_write_methods_to_primary(
|
||||
self, mock_clear, mock_set, middleware, post_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ routes write methods to primary database."""
|
||||
response = middleware(post_request)
|
||||
|
||||
mock_set.assert_called_once_with(False) # Primary database
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_with_read_methods_waits_for_process_view(
|
||||
self, mock_clear, middleware, get_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ with read methods waits for process_view."""
|
||||
response = middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request):
|
||||
"""Test __call__ always cleans up context."""
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_cleans_up_context_on_exception(self, mock_clear, middleware, get_request, mock_get_response):
|
||||
"""Test __call__ cleans up context even if get_response raises."""
|
||||
mock_get_response.side_effect = Exception("Test exception")
|
||||
|
||||
with pytest.raises(Exception, match="Test exception"):
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProcessView:
|
||||
"""Test cases for process_view method functionality."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and use_read_replica=True."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_false(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and use_read_replica=False."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_no_replica_attribute(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and no use_read_replica attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False) # Default to primary
|
||||
assert result is None
|
||||
|
||||
def test_with_write_method_ignores_view_attributes(self, middleware, post_request):
|
||||
"""Test process_view with write methods ignores view attributes."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # This should be ignored for POST
|
||||
|
||||
result = middleware.process_view(post_request, view_func, (), {})
|
||||
|
||||
assert result is None # Should not process for write methods
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReplicaDecisionLogic:
|
||||
"""Test cases for replica decision logic methods."""
|
||||
|
||||
def test_should_use_read_replica_with_true_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns True for True attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_should_use_read_replica_with_false_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns False for False attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware):
|
||||
"""Test _should_use_read_replica defaults to False for missing attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAttributeDetection:
|
||||
"""Test cases for view attribute detection methods."""
|
||||
|
||||
def test_get_use_replica_attribute_function_based_view(self, middleware):
|
||||
"""Test _get_use_replica_attribute with function-based view."""
|
||||
# Test with True
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is True
|
||||
|
||||
# Test with False
|
||||
view_func.use_read_replica = False
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is False
|
||||
|
||||
# Test with no attribute
|
||||
view_func = Mock(spec=[])
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is None
|
||||
|
||||
def test_get_use_replica_attribute_django_cbv(self, middleware):
|
||||
"""Test _get_use_replica_attribute with Django CBV wrapper."""
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = True
|
||||
view_func = Mock()
|
||||
view_func.view_class = view_class
|
||||
# Remove use_read_replica from view_func to ensure it checks view_class
|
||||
del view_func.use_read_replica
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_drf_wrapper(self, middleware):
|
||||
"""Test _get_use_replica_attribute with DRF wrapper."""
|
||||
|
||||
# Create a real object to avoid Mock issues
|
||||
class ViewClass:
|
||||
use_read_replica = True
|
||||
|
||||
class ViewFunc:
|
||||
cls = ViewClass()
|
||||
|
||||
view_func = ViewFunc()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_priority_order(self, middleware):
|
||||
"""Test attribute priority: direct > view_class > cls."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # Direct attribute (highest priority)
|
||||
|
||||
# Add conflicting attributes with lower priority
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = False
|
||||
view_func.view_class = view_class
|
||||
|
||||
cls = Mock()
|
||||
cls.use_read_replica = False
|
||||
view_func.cls = cls
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True # Should use direct attribute
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
(1, True),
|
||||
(0, False),
|
||||
("yes", True),
|
||||
("", False),
|
||||
([], False),
|
||||
([1], True),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_should_use_read_replica_truthy_falsy_values(self, middleware, value, expected):
|
||||
"""Test _should_use_read_replica with various truthy/falsy values."""
|
||||
|
||||
# Create a real object to test the attribute handling
|
||||
class TestView:
|
||||
pass
|
||||
|
||||
view_func = TestView()
|
||||
view_func.use_read_replica = value
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExceptionHandling:
|
||||
"""Test cases for exception handling and cleanup."""
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_process_exception_cleans_up_context(self, mock_clear, middleware, request_factory):
|
||||
"""Test process_exception cleans up context."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
result = middleware.process_exception(request, exception)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert result is None # Don't handle the exception
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_integration_full_request_cycle(self, mock_clear, mock_set, middleware, request_factory, mock_get_response):
|
||||
"""Test complete request cycle from __call__ through process_view."""
|
||||
request = request_factory.get("/api/test/")
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
# Call middleware and process_view manually
|
||||
response = middleware(request)
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRealViewIntegration:
|
||||
"""Test middleware with real Django/DRF view classes."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_django_class_based_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with actual Django CBV."""
|
||||
|
||||
class TestView(View):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate Django's URL resolver creating a view wrapper
|
||||
view_func = TestView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_api_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF APIView."""
|
||||
|
||||
class TestAPIView(APIView):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF's URL pattern creating a view wrapper
|
||||
view_func = TestAPIView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_viewset(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF ViewSet."""
|
||||
|
||||
class TestViewSet(ViewSet):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF router creating viewset action
|
||||
view_func = TestViewSet.as_view({"get": "list"})
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def test_process_view_with_none_view_func(self, middleware, request_factory):
|
||||
"""Test process_view handles None view_func gracefully."""
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
result = middleware.process_view(request, None, (), {})
|
||||
|
||||
assert result is None # Should not crash
|
||||
|
||||
def test_get_use_replica_attribute_with_attribute_error(self, middleware):
|
||||
"""Test _get_use_replica_attribute with view that raises AttributeError."""
|
||||
|
||||
# Create a view class that raises AttributeError on access
|
||||
class ProblematicView:
|
||||
def __getattr__(self, name):
|
||||
if name == "use_read_replica":
|
||||
raise AttributeError("Simulated attribute error")
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
|
||||
view_func = ProblematicView()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is None # Should handle gracefully
|
||||
|
||||
def test_multiple_exception_calls_are_safe(self, middleware, request_factory):
|
||||
"""Test that multiple calls to process_exception don't cause issues."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
# Call multiple times
|
||||
result1 = middleware.process_exception(request, exception)
|
||||
result2 = middleware.process_exception(request, exception)
|
||||
|
||||
assert result1 is None # Both should return None safely
|
||||
assert result2 is None
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(create_user):
|
||||
"""Create a test workspace"""
|
||||
return Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
owner=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(workspace, create_user):
|
||||
"""Create a test project"""
|
||||
return Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(project):
|
||||
"""Create a test state"""
|
||||
return State.objects.create(
|
||||
name="Todo",
|
||||
project=project,
|
||||
group="backlog",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue(workspace, project, state, create_user):
|
||||
"""Create a test issue"""
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueCommentModel:
|
||||
"""Test the IssueComment model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that creating a comment automatically creates a description"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is a test comment</p>"
|
||||
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.id is not None
|
||||
assert issue_comment.comment_stripped == "This is a test comment"
|
||||
assert issue_comment.description_id is not None
|
||||
|
||||
# Verify description was created
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description is not None
|
||||
assert description.description_html == comment_html
|
||||
assert description.description_json == comment_json
|
||||
assert description.description_stripped == "This is a test comment"
|
||||
assert description.workspace_id == workspace.id
|
||||
assert description.project_id == project.id
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment updates its associated description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
# Refresh from database
|
||||
issue_comment.refresh_from_db()
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify comment was updated
|
||||
assert issue_comment.comment_stripped == "Updated comment"
|
||||
assert issue_comment.description_id == initial_description_id # Same description object
|
||||
|
||||
# Verify description was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_json == updated_json
|
||||
assert updated_description.description_stripped == "Updated comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
|
||||
"""Test that only changed fields are updated in description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update only the HTML (not JSON)
|
||||
updated_html = "<p>Updated comment only HTML</p>"
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
# comment_json remains the same
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify HTML was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_stripped == "Updated comment only HTML"
|
||||
|
||||
# Verify JSON remained the same
|
||||
assert updated_description.description_json == initial_json
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
|
||||
"""Test that description is not updated when comment content doesn't change"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Test comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Save without changing content
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify description was not updated (updated_at should be the same)
|
||||
# Note: This test assumes updated_at is not changed when no fields change
|
||||
assert updated_description.description_html == initial_html
|
||||
assert updated_description.description_json == initial_json
|
||||
assert updated_description.description_stripped == "Test comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
|
||||
# Arrange - Create comment and manually remove description (simulating legacy data)
|
||||
initial_html = "<p>Legacy comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Simulate legacy data by removing the description
|
||||
if issue_comment.description_id:
|
||||
Description.objects.filter(pk=issue_comment.description_id).delete()
|
||||
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
assert issue_comment.description_id is None
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated legacy comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
# Verify description was created
|
||||
assert issue_comment.description_id is not None
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_html == updated_html
|
||||
assert description.description_json == updated_json
|
||||
assert description.description_stripped == "Updated legacy comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
|
||||
"""Test that HTML tags are properly stripped from comment_html"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == "This is bold and italic text"
|
||||
|
||||
# Verify description has the same stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_stripped == "This is bold and italic text"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
|
||||
"""Test that empty HTML results in empty comment_stripped"""
|
||||
# Arrange
|
||||
comment_html = ""
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == ""
|
||||
|
||||
# Verify description was created with empty stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
|
||||
assert description.description_stripped is None
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceModel:
|
||||
"""Test the Workspace model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_creation(self, create_user):
|
||||
"""Test creating a workspace"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace.id is not None
|
||||
assert workspace.name == "Test Workspace"
|
||||
assert workspace.slug == "test-workspace"
|
||||
assert workspace.owner == create_user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_member_creation(self, create_user):
|
||||
"""Test creating a workspace member"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Create a workspace member
|
||||
workspace_member = WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace_member.id is not None
|
||||
assert workspace_member.workspace == workspace
|
||||
assert workspace_member.member == create_user
|
||||
assert workspace_member.role == 20
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
User,
|
||||
IssueAssignee,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers.workspace import IssueRecentVisitSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueRecentVisitSerializer:
|
||||
"""Test the IssueRecentVisitSerializer"""
|
||||
|
||||
def test_issue_recent_visit_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
|
||||
test_user_1 = User.objects.create(email="test_user_1@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# To test for deleted issue assignee
|
||||
test_user_2 = User.objects.create(
|
||||
email="test_user_2@example.com",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
username="some user name",
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=test_user_1)
|
||||
|
||||
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
|
||||
|
||||
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
|
||||
ProjectMember.objects.create(project=project, member=test_user_2)
|
||||
|
||||
issue = Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
|
||||
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
|
||||
|
||||
# Deleted issue assignee
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=test_user_2,
|
||||
project=project,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
serialized_data = IssueRecentVisitSerializer(
|
||||
issue,
|
||||
).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "assignees" in serialized_data
|
||||
assert "project_identifier" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Issue"
|
||||
assert serialized_data["project_identifier"] == "TEST-PROJECT"
|
||||
|
||||
# Only including non-deleted issue assignees
|
||||
assert serialized_data["assignees"] == [test_user_1.id]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from plane.app.serializers import LabelSerializer
|
||||
from plane.db.models import Project, Label
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLabelSerializer:
|
||||
"""Test the LabelSerializer"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_valid_data(self, db, workspace):
|
||||
"""Test creating a label with valid data"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
data={"name": "Test Label"},
|
||||
context={"project_id": project.id},
|
||||
)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.errors == {}
|
||||
serializer.save(project_id=project.id)
|
||||
|
||||
label = Label.objects.all().first()
|
||||
assert label.name == "Test Label"
|
||||
assert label.project == project
|
||||
assert label
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_duplicate_name(self, db, workspace):
|
||||
"""Test creating a label with a duplicate name"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
|
||||
Label.objects.create(name="Test Label", project=project)
|
||||
|
||||
serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.api.serializers import WorkspaceLiteSerializer
|
||||
from plane.db.models import Workspace, User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceLiteSerializer:
|
||||
"""Test the WorkspaceLiteSerializer"""
|
||||
|
||||
def test_workspace_lite_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(email="test@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# Create a workspace with explicit ID to test serialization
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner)
|
||||
|
||||
# Serialize the workspace
|
||||
serialized_data = WorkspaceLiteSerializer(workspace).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "slug" in serialized_data
|
||||
assert "id" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Workspace"
|
||||
assert serialized_data["slug"] == "test-workspace"
|
||||
assert str(serialized_data["id"]) == str(workspace_id)
|
||||
|
||||
def test_workspace_lite_serializer_read_only(self, db):
|
||||
"""Test that the serializer fields are read-only"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(email="test2@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner)
|
||||
|
||||
# Try to update via serializer
|
||||
serializer = WorkspaceLiteSerializer(workspace, data={"name": "Updated Name", "slug": "updated-slug"})
|
||||
|
||||
# Serializer should be valid (since read-only fields are ignored)
|
||||
assert serializer.is_valid()
|
||||
|
||||
# Save should not update the read-only fields
|
||||
updated_workspace = serializer.save()
|
||||
assert updated_workspace.name == "Test Workspace"
|
||||
assert updated_workspace.slug == "test-workspace"
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestS3StorageSignedURLExpiration:
|
||||
"""Test the configurable signed URL expiration in S3Storage"""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_default_expiration_without_env_variable(self, mock_boto3):
|
||||
"""Test that default expiration is 3600 seconds when env variable is not set"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert default expiration is 3600
|
||||
assert storage.signed_url_expiration == 3600
|
||||
|
||||
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_custom_expiration_with_env_variable(self, mock_boto3):
|
||||
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert expiration is 30
|
||||
assert storage.signed_url_expiration == 30
|
||||
|
||||
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_custom_expiration_multiple_values(self, mock_boto3):
|
||||
"""Test that expiration works with different custom values"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert expiration is 300
|
||||
assert storage.signed_url_expiration == 300
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_post uses the configured default expiration"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_post.return_value = {
|
||||
"url": "https://test-url.com",
|
||||
"fields": {},
|
||||
}
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_post without explicit expiration
|
||||
storage.generate_presigned_post("test-object", "image/png", 1024)
|
||||
|
||||
# Assert that the boto3 method was called with the default expiration (3600)
|
||||
mock_s3_client.generate_presigned_post.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 3600
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "60",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_post uses custom expiration from env variable"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_post.return_value = {
|
||||
"url": "https://test-url.com",
|
||||
"fields": {},
|
||||
}
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_post without explicit expiration
|
||||
storage.generate_presigned_post("test-object", "image/png", 1024)
|
||||
|
||||
# Assert that the boto3 method was called with custom expiration (60)
|
||||
mock_s3_client.generate_presigned_post.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 60
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_url uses the configured default expiration"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url without explicit expiration
|
||||
storage.generate_presigned_url("test-object")
|
||||
|
||||
# Assert that the boto3 method was called with the default expiration (3600)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 3600
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "30",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_url uses custom expiration from env variable"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url without explicit expiration
|
||||
storage.generate_presigned_url("test-object")
|
||||
|
||||
# Assert that the boto3 method was called with custom expiration (30)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 30
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "30",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_explicit_expiration_overrides_default(self, mock_boto3):
|
||||
"""Test that explicit expiration parameter overrides the default"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url with explicit expiration=120
|
||||
storage.generate_presigned_url("test-object", expiration=120)
|
||||
|
||||
# Assert that the boto3 method was called with explicit expiration (120)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 120
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from plane.utils.url import (
|
||||
contains_url,
|
||||
is_valid_url,
|
||||
normalize_url_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContainsURL:
|
||||
"""Test the contains_url function"""
|
||||
|
||||
def test_contains_url_with_http_protocol(self):
|
||||
"""Test contains_url with HTTP protocol URLs"""
|
||||
assert contains_url("Check out http://example.com") is True
|
||||
assert contains_url("Visit http://google.com/search") is True
|
||||
assert contains_url("http://localhost:8000") is True
|
||||
|
||||
def test_contains_url_with_https_protocol(self):
|
||||
"""Test contains_url with HTTPS protocol URLs"""
|
||||
assert contains_url("Check out https://example.com") is True
|
||||
assert contains_url("Visit https://google.com/search") is True
|
||||
assert contains_url("https://secure.example.com") is True
|
||||
|
||||
def test_contains_url_with_www_prefix(self):
|
||||
"""Test contains_url with www prefix"""
|
||||
assert contains_url("Visit www.example.com") is True
|
||||
assert contains_url("Check www.google.com") is True
|
||||
assert contains_url("Go to www.test-site.org") is True
|
||||
|
||||
def test_contains_url_with_domain_patterns(self):
|
||||
"""Test contains_url with domain patterns"""
|
||||
assert contains_url("Visit example.com") is True
|
||||
assert contains_url("Check google.org") is True
|
||||
assert contains_url("Go to test-site.co.uk") is True
|
||||
assert contains_url("Visit sub.domain.com") is True
|
||||
|
||||
def test_contains_url_with_ip_addresses(self):
|
||||
"""Test contains_url with IP addresses"""
|
||||
assert contains_url("Connect to 192.168.1.1") is True
|
||||
assert contains_url("Visit 10.0.0.1") is True
|
||||
assert contains_url("Check 127.0.0.1") is True
|
||||
assert contains_url("Go to 8.8.8.8") is True
|
||||
|
||||
def test_contains_url_case_insensitive(self):
|
||||
"""Test contains_url is case insensitive"""
|
||||
assert contains_url("Check HTTP://EXAMPLE.COM") is True
|
||||
assert contains_url("Visit WWW.GOOGLE.COM") is True
|
||||
assert contains_url("Go to Https://Test.Com") is True
|
||||
|
||||
def test_contains_url_with_no_urls(self):
|
||||
"""Test contains_url with text that doesn't contain URLs"""
|
||||
assert contains_url("This is just plain text") is False
|
||||
assert contains_url("No URLs here!") is False
|
||||
assert contains_url("com org net") is False # Just TLD words
|
||||
assert contains_url("192.168") is False # Incomplete IP
|
||||
assert contains_url("") is False # Empty string
|
||||
|
||||
def test_contains_url_edge_cases(self):
|
||||
"""Test contains_url with edge cases"""
|
||||
assert contains_url("example.c") is False # TLD too short
|
||||
assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255)
|
||||
assert contains_url("just-a-hyphen") is False # No domain
|
||||
assert contains_url("www.") is False # Incomplete www - needs at least one char after dot
|
||||
|
||||
def test_contains_url_length_limit_under_1000(self):
|
||||
"""Test contains_url with input under 1000 characters containing URLs"""
|
||||
# Create a string under 1000 characters with a URL
|
||||
text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars
|
||||
assert len(text_with_url) < 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
# Test with exactly 1000 characters
|
||||
text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_exact_1000) == 1000
|
||||
assert contains_url(text_exact_1000) is True
|
||||
|
||||
def test_contains_url_length_limit_over_1000(self):
|
||||
"""Test contains_url with input over 1000 characters returns False"""
|
||||
# Create a string over 1000 characters with a URL
|
||||
text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars
|
||||
assert len(text_with_url) > 1000
|
||||
assert contains_url(text_with_url) is False
|
||||
|
||||
# Test with much longer input
|
||||
long_text_with_url = "a" * 5000 + " https://example.com"
|
||||
assert contains_url(long_text_with_url) is False
|
||||
|
||||
def test_contains_url_length_limit_exactly_1000(self):
|
||||
"""Test contains_url with input exactly 1000 characters"""
|
||||
# Test with exactly 1000 characters without URL
|
||||
text_no_url = "a" * 1000
|
||||
assert len(text_no_url) == 1000
|
||||
assert contains_url(text_no_url) is False
|
||||
|
||||
# Test with exactly 1000 characters with URL at the end
|
||||
text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_with_url) == 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
def test_contains_url_line_length_scenarios(self):
|
||||
"""Test contains_url with realistic line length scenarios"""
|
||||
# Test with multiline input where total is under 1000 but we test line processing
|
||||
# Short lines with URL
|
||||
multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(multiline_short) is True
|
||||
|
||||
# Multiple lines under total limit
|
||||
multiline_text = "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
def test_contains_url_total_length_vs_line_length(self):
|
||||
"""Test the interaction between total length limit and line processing"""
|
||||
# Test that total length limit takes precedence
|
||||
# Even if individual lines would be processed, total > 1000 means immediate False
|
||||
over_limit_text = "a" * 1001 # No URL, but over total limit
|
||||
assert contains_url(over_limit_text) is False
|
||||
|
||||
# Test that under total limit, line processing works normally
|
||||
under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total
|
||||
assert len(under_limit_with_url) < 1000
|
||||
assert contains_url(under_limit_with_url) is True
|
||||
|
||||
def test_contains_url_multiline_mixed_lengths(self):
|
||||
"""Test contains_url with multiple lines of different lengths"""
|
||||
# Test realistic multiline scenario under 1000 chars total
|
||||
multiline_text = (
|
||||
"Short line\n"
|
||||
+ "a" * 400
|
||||
+ "https://example.com\n" # Line with URL
|
||||
+ "b" * 300 # Another line
|
||||
)
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
# Test multiline without URLs
|
||||
multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300
|
||||
assert len(multiline_no_url) < 1000
|
||||
assert contains_url(multiline_no_url) is False
|
||||
|
||||
def test_contains_url_edge_cases_with_length_limits(self):
|
||||
"""Test contains_url edge cases related to length limits"""
|
||||
# Empty string
|
||||
assert contains_url("") is False
|
||||
|
||||
# Very short string with URL
|
||||
assert contains_url("http://a.co") is True
|
||||
|
||||
# String with newlines and mixed content
|
||||
mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(mixed_content) is True
|
||||
|
||||
# String with many newlines under total limit
|
||||
many_newlines = "\n" * 500 + "https://example.com"
|
||||
assert len(many_newlines) < 1000
|
||||
assert contains_url(many_newlines) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIsValidURL:
|
||||
"""Test the is_valid_url function"""
|
||||
|
||||
def test_is_valid_url_with_valid_urls(self):
|
||||
"""Test is_valid_url with valid URLs"""
|
||||
assert is_valid_url("https://example.com") is True
|
||||
assert is_valid_url("http://google.com") is True
|
||||
assert is_valid_url("https://sub.domain.com/path") is True
|
||||
assert is_valid_url("http://localhost:8000") is True
|
||||
assert is_valid_url("https://example.com/path?query=1") is True
|
||||
assert is_valid_url("ftp://files.example.com") is True
|
||||
|
||||
def test_is_valid_url_with_invalid_urls(self):
|
||||
"""Test is_valid_url with invalid URLs"""
|
||||
assert is_valid_url("not a url") is False
|
||||
assert is_valid_url("example.com") is False # No scheme
|
||||
assert is_valid_url("https://") is False # No netloc
|
||||
assert is_valid_url("") is False # Empty string
|
||||
assert is_valid_url("://example.com") is False # No scheme
|
||||
assert is_valid_url("https:/example.com") is False # Malformed
|
||||
|
||||
def test_is_valid_url_with_non_string_input(self):
|
||||
"""Test is_valid_url with non-string input"""
|
||||
assert is_valid_url(None) is False
|
||||
assert is_valid_url([]) is False
|
||||
assert is_valid_url({}) is False
|
||||
|
||||
def test_is_valid_url_with_special_schemes(self):
|
||||
"""Test is_valid_url with special URL schemes"""
|
||||
assert is_valid_url("ftp://ftp.example.com") is True
|
||||
assert is_valid_url("mailto:user@example.com") is False
|
||||
assert is_valid_url("file:///path/to/file") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeURLPath:
|
||||
"""Test the normalize_url_path function"""
|
||||
|
||||
def test_normalize_url_path_with_multiple_slashes(self):
|
||||
"""Test normalize_url_path with multiple consecutive slashes"""
|
||||
result = normalize_url_path("https://example.com//foo///bar//baz")
|
||||
assert result == "https://example.com/foo/bar/baz"
|
||||
|
||||
def test_normalize_url_path_with_query_and_fragment(self):
|
||||
"""Test normalize_url_path preserves query and fragment"""
|
||||
result = normalize_url_path("https://example.com//foo///bar//baz?x=1&y=2#fragment")
|
||||
assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment"
|
||||
|
||||
def test_normalize_url_path_with_no_redundant_slashes(self):
|
||||
"""Test normalize_url_path with already normalized URL"""
|
||||
url = "https://example.com/foo/bar/baz?x=1#fragment"
|
||||
result = normalize_url_path(url)
|
||||
assert result == url
|
||||
|
||||
def test_normalize_url_path_with_root_path(self):
|
||||
"""Test normalize_url_path with root path"""
|
||||
result = normalize_url_path("https://example.com//")
|
||||
assert result == "https://example.com/"
|
||||
|
||||
def test_normalize_url_path_with_empty_path(self):
|
||||
"""Test normalize_url_path with empty path"""
|
||||
result = normalize_url_path("https://example.com")
|
||||
assert result == "https://example.com"
|
||||
|
||||
def test_normalize_url_path_with_complex_path(self):
|
||||
"""Test normalize_url_path with complex path structure"""
|
||||
result = normalize_url_path("https://example.com///api//v1///users//123//profile")
|
||||
assert result == "https://example.com/api/v1/users/123/profile"
|
||||
|
||||
def test_normalize_url_path_with_different_schemes(self):
|
||||
"""Test normalize_url_path with different URL schemes"""
|
||||
# HTTP
|
||||
result = normalize_url_path("http://example.com//path")
|
||||
assert result == "http://example.com/path"
|
||||
|
||||
# FTP
|
||||
result = normalize_url_path("ftp://ftp.example.com//files//document.txt")
|
||||
assert result == "ftp://ftp.example.com/files/document.txt"
|
||||
|
||||
def test_normalize_url_path_with_port(self):
|
||||
"""Test normalize_url_path with port number"""
|
||||
result = normalize_url_path("https://example.com:8080//api//v1")
|
||||
assert result == "https://example.com:8080/api/v1"
|
||||
|
||||
def test_normalize_url_path_edge_cases(self):
|
||||
"""Test normalize_url_path with edge cases"""
|
||||
# Many consecutive slashes
|
||||
result = normalize_url_path("https://example.com///////path")
|
||||
assert result == "https://example.com/path"
|
||||
|
||||
# Mixed single and multiple slashes
|
||||
result = normalize_url_path("https://example.com/a//b/c///d")
|
||||
assert result == "https://example.com/a/b/c/d"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUUIDUtils:
|
||||
"""Test the UUID utilities"""
|
||||
|
||||
def test_is_valid_uuid_with_valid_uuid(self):
|
||||
"""Test is_valid_uuid with a valid UUID"""
|
||||
# Generate a valid UUID
|
||||
valid_uuid = str(uuid.uuid4())
|
||||
assert is_valid_uuid(valid_uuid) is True
|
||||
|
||||
def test_is_valid_uuid_with_invalid_uuid(self):
|
||||
"""Test is_valid_uuid with invalid UUID strings"""
|
||||
# Test with different invalid formats
|
||||
assert is_valid_uuid("not-a-uuid") is False
|
||||
assert is_valid_uuid("123456789") is False
|
||||
assert is_valid_uuid("") is False
|
||||
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
|
||||
|
||||
def test_convert_uuid_to_integer(self):
|
||||
"""Test convert_uuid_to_integer function"""
|
||||
# Create a known UUID
|
||||
test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
|
||||
|
||||
# Convert to integer
|
||||
result = convert_uuid_to_integer(test_uuid)
|
||||
|
||||
# Check that the result is an integer
|
||||
assert isinstance(result, int)
|
||||
|
||||
# Ensure consistent results with the same input
|
||||
assert convert_uuid_to_integer(test_uuid) == result
|
||||
|
||||
# Different UUIDs should produce different integers
|
||||
different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert convert_uuid_to_integer(different_uuid) != result
|
||||
|
||||
def test_convert_uuid_to_integer_string_input(self):
|
||||
"""Test convert_uuid_to_integer handles string UUID"""
|
||||
# Test with a UUID string
|
||||
test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
test_uuid = uuid.UUID(test_uuid_str)
|
||||
|
||||
# Should get the same result whether passing UUID or string
|
||||
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)
|
||||
Reference in New Issue
Block a user