This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -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)