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,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,384 @@
|
||||
# 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 rest_framework import status
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Cycle, Project, ProjectMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as a member"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle_data():
|
||||
"""Sample cycle data for tests"""
|
||||
return {
|
||||
"name": "Test Cycle",
|
||||
"description": "A test cycle for unit tests",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def draft_cycle_data():
|
||||
"""Sample draft cycle data (no dates)"""
|
||||
return {
|
||||
"name": "Draft Cycle",
|
||||
"description": "A draft cycle without dates",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_cycle(db, project, create_user):
|
||||
"""Create a test cycle"""
|
||||
return Cycle.objects.create(
|
||||
name="Existing Cycle",
|
||||
description="An existing cycle",
|
||||
start_date=timezone.now() + timedelta(days=1),
|
||||
end_date=timezone.now() + timedelta(days=7),
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCycleListCreateAPIEndpoint:
|
||||
"""Test Cycle List and Create API Endpoint"""
|
||||
|
||||
def get_cycle_url(self, workspace_slug, project_id):
|
||||
"""Helper to get cycle endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data):
|
||||
"""Test successful cycle creation"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
assert Cycle.objects.count() == 1
|
||||
|
||||
created_cycle = Cycle.objects.first()
|
||||
assert created_cycle.name == cycle_data["name"]
|
||||
assert created_cycle.description == cycle_data["description"]
|
||||
assert created_cycle.project == project
|
||||
assert created_cycle.owned_by_id is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
|
||||
"""Test cycle creation with invalid data"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Test with empty data
|
||||
response = api_key_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test with missing name
|
||||
response = api_key_client.post(url, {"description": "Test cycle"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project):
|
||||
"""Test cycle creation with invalid date combination (only start_date)"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
invalid_data = {
|
||||
"name": "Invalid Cycle",
|
||||
"start_date": (timezone.now() + timedelta(days=1)).isoformat(),
|
||||
# Missing end_date
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, invalid_data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Both start date and end date are either required or are to be null" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_with_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating cycle with external ID"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
cycle_data = {
|
||||
"name": "External Cycle",
|
||||
"description": "A cycle with external ID",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
created_cycle = Cycle.objects.first()
|
||||
assert created_cycle.external_id == "ext-123"
|
||||
assert created_cycle.external_source == "github"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user):
|
||||
"""Test creating cycle with duplicate external ID"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create first cycle
|
||||
Cycle.objects.create(
|
||||
name="First Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-123",
|
||||
external_source="github",
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Try to create second cycle with same external ID
|
||||
cycle_data = {
|
||||
"name": "Second Cycle",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
"owned_by": create_user.id,
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, cycle_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user):
|
||||
"""Test successful cycle listing"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create additional cycles
|
||||
Cycle.objects.create(
|
||||
name="Cycle 2",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=timezone.now() + timedelta(days=10),
|
||||
end_date=timezone.now() + timedelta(days=17),
|
||||
owned_by=create_user,
|
||||
)
|
||||
Cycle.objects.create(
|
||||
name="Cycle 3",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=timezone.now() + timedelta(days=20),
|
||||
end_date=timezone.now() + timedelta(days=27),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
assert len(response.data["results"]) == 3 # Including create_cycle fixture
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user):
|
||||
"""Test cycle listing with different view filters"""
|
||||
url = self.get_cycle_url(workspace.slug, project.id)
|
||||
|
||||
# Create cycles in different states
|
||||
now = timezone.now()
|
||||
|
||||
# Current cycle (started but not ended)
|
||||
Cycle.objects.create(
|
||||
name="Current Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now - timedelta(days=1),
|
||||
end_date=now + timedelta(days=6),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Upcoming cycle
|
||||
Cycle.objects.create(
|
||||
name="Upcoming Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now + timedelta(days=1),
|
||||
end_date=now + timedelta(days=8),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Completed cycle
|
||||
Cycle.objects.create(
|
||||
name="Completed Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
start_date=now - timedelta(days=10),
|
||||
end_date=now - timedelta(days=3),
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Draft cycle
|
||||
Cycle.objects.create(
|
||||
name="Draft Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Test current cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "current"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
assert response.data[0]["name"] == "Current Cycle"
|
||||
|
||||
# Test upcoming cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "upcoming"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Upcoming Cycle"
|
||||
|
||||
# Test completed cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "completed"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Completed Cycle"
|
||||
|
||||
# Test draft cycles
|
||||
response = api_key_client.get(url, {"cycle_view": "draft"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Draft Cycle"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestCycleDetailAPIEndpoint:
|
||||
"""Test Cycle Detail API Endpoint"""
|
||||
|
||||
def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id):
|
||||
"""Helper to get cycle detail endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle retrieval"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_cycle.id)
|
||||
assert response.data["name"] == create_cycle.name
|
||||
assert response.data["description"] == create_cycle.description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cycle_not_found(self, api_key_client, workspace, project):
|
||||
"""Test getting non-existent cycle"""
|
||||
fake_id = uuid4()
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle update"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
update_data = {
|
||||
"name": f"Updated Cycle {uuid4()}",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
create_cycle.refresh_from_db()
|
||||
assert create_cycle.name == update_data["name"]
|
||||
assert create_cycle.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test cycle update with invalid data"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
update_data = {"name": ""}
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
# This might be 400 if name is required, or 200 if empty names are allowed
|
||||
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cycle_with_external_id_conflict(
|
||||
self, api_key_client, workspace, project, create_cycle, create_user
|
||||
):
|
||||
"""Test cycle update with conflicting external ID"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
# Create another cycle with external ID
|
||||
Cycle.objects.create(
|
||||
name="Another Cycle",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-456",
|
||||
external_source="github",
|
||||
owned_by=create_user,
|
||||
)
|
||||
|
||||
# Try to update cycle with same external ID
|
||||
update_data = {
|
||||
"external_id": "ext-456",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test successful cycle deletion"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Cycle.objects.filter(id=create_cycle.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle):
|
||||
"""Test that cycle includes issue metrics annotations"""
|
||||
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Check that metrics are included in response
|
||||
cycle_data = response.data
|
||||
assert "total_issues" in cycle_data
|
||||
assert "completed_issues" in cycle_data
|
||||
assert "cancelled_issues" in cycle_data
|
||||
assert "started_issues" in cycle_data
|
||||
assert "unstarted_issues" in cycle_data
|
||||
assert "backlog_issues" in cycle_data
|
||||
|
||||
# All should be 0 for a new cycle
|
||||
assert cycle_data["total_issues"] == 0
|
||||
assert cycle_data["completed_issues"] == 0
|
||||
assert cycle_data["cancelled_issues"] == 0
|
||||
assert cycle_data["started_issues"] == 0
|
||||
assert cycle_data["unstarted_issues"] == 0
|
||||
assert cycle_data["backlog_issues"] == 0
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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 rest_framework import status
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.db.models import Label, Project, ProjectMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""Create a test project with the user as a member"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
is_active=True,
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def label_data():
|
||||
"""Sample label data for tests"""
|
||||
return {
|
||||
"name": "Test Label",
|
||||
"color": "#FF5733",
|
||||
"description": "A test label for unit tests",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_label(db, project, create_user):
|
||||
"""Create a test label"""
|
||||
return Label.objects.create(
|
||||
name="Existing Label",
|
||||
color="#00FF00",
|
||||
description="An existing label",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelListCreateAPIEndpoint:
|
||||
"""Test Label List and Create API Endpoint"""
|
||||
|
||||
def get_label_url(self, workspace_slug, project_id):
|
||||
"""Helper to get label endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_success(self, api_key_client, workspace, project, label_data):
|
||||
"""Test successful label creation"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Label.objects.count() == 1
|
||||
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.name == label_data["name"]
|
||||
assert created_label.color == label_data["color"]
|
||||
assert created_label.description == label_data["description"]
|
||||
assert created_label.project == project
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_invalid_data(self, api_key_client, workspace, project):
|
||||
"""Test label creation with invalid data"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Test with empty data
|
||||
response = api_key_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test with missing name
|
||||
response = api_key_client.post(url, {"color": "#FF5733"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_with_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating label with external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
label_data = {
|
||||
"name": "External Label",
|
||||
"color": "#FF5733",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
created_label = Label.objects.first()
|
||||
assert created_label.external_id == "ext-123"
|
||||
assert created_label.external_source == "github"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_label_duplicate_external_id(self, api_key_client, workspace, project):
|
||||
"""Test creating label with duplicate external ID"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create first label
|
||||
Label.objects.create(
|
||||
name="First Label",
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
external_id="ext-123",
|
||||
external_source="github",
|
||||
)
|
||||
|
||||
# Try to create second label with same external ID
|
||||
label_data = {
|
||||
"name": "Second Label",
|
||||
"external_id": "ext-123",
|
||||
"external_source": "github",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, label_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "same external id" in response.data["error"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_labels_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label listing"""
|
||||
url = self.get_label_url(workspace.slug, project.id)
|
||||
|
||||
# Create additional labels
|
||||
Label.objects.create(name="Label 2", project=project, workspace=workspace, color="#00FF00")
|
||||
Label.objects.create(name="Label 3", project=project, workspace=workspace, color="#0000FF")
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "results" in response.data
|
||||
assert len(response.data["results"]) == 3 # Including create_label fixture
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestLabelDetailAPIEndpoint:
|
||||
"""Test Label Detail API Endpoint"""
|
||||
|
||||
def get_label_detail_url(self, workspace_slug, project_id, label_id):
|
||||
"""Helper to get label detail endpoint URL"""
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label retrieval"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == create_label.id
|
||||
assert response.data["name"] == create_label.name
|
||||
assert response.data["color"] == create_label.color
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_label_not_found(self, api_key_client, workspace, project):
|
||||
"""Test getting non-existent label"""
|
||||
from uuid import uuid4
|
||||
|
||||
fake_id = uuid4()
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, fake_id)
|
||||
|
||||
response = api_key_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label update"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {
|
||||
"name": f"Updated Label {uuid4()}",
|
||||
}
|
||||
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
create_label.refresh_from_db()
|
||||
assert create_label.name == update_data["name"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_label_invalid_data(self, api_key_client, workspace, project, create_label):
|
||||
"""Test label update with invalid data"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
update_data = {"name": ""}
|
||||
response = api_key_client.patch(url, update_data, format="json")
|
||||
|
||||
# This might be 400 if name is required, or 200 if empty names are allowed
|
||||
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_label_success(self, api_key_client, workspace, project, create_label):
|
||||
"""Test successful label deletion"""
|
||||
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
|
||||
|
||||
response = api_key_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Label.objects.filter(id=create_label.id).exists()
|
||||
@@ -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,402 @@
|
||||
# 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 datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(self, session_client, create_user, api_token_data):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(self, session_client, create_bot_user, api_token_data):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert "token" not in response.data # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that token value cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_token = create_api_token_for_user.token
|
||||
update_data = {"token": "plane_api_malicious_token_value"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.token == original_token
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that user_type cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {"user_type": 1}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.user_type == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_service_token(self, session_client, create_user):
|
||||
"""Test that service tokens cannot be modified through user token endpoint"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||
update_data = {"label": "Hacked Service Token"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
service_token.refresh_from_db()
|
||||
assert service_token.label == "Service Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from django.test import Client
|
||||
from django.core.exceptions import ValidationError
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.db.models import User
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.license.models import Instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_instance(db):
|
||||
"""Create and configure an instance for authentication tests"""
|
||||
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
|
||||
|
||||
# Create or update instance with all required fields
|
||||
instance, _ = Instance.objects.update_or_create(
|
||||
id=instance_id,
|
||||
defaults={
|
||||
"instance_name": "Test Instance",
|
||||
"instance_id": str(uuid.uuid4()),
|
||||
"current_version": "1.0.0",
|
||||
"domain": "http://localhost:8000",
|
||||
"last_checked_at": timezone.now(),
|
||||
"is_setup_done": True,
|
||||
},
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def django_client():
|
||||
"""Return a Django test client with User-Agent header for handling redirects"""
|
||||
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicLinkGenerate:
|
||||
"""Test magic link generation functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic link tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with empty data"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the empty email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, api_client, setup_user, setup_instance):
|
||||
"""Test magic link generation with invalid email format"""
|
||||
url = reverse("magic-generate")
|
||||
try:
|
||||
response = api_client.post(url, {"email": "useremail.com"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
except ValidationError:
|
||||
# If a ValidationError is raised directly, that's also acceptable
|
||||
# as it indicates the invalid email was rejected
|
||||
assert True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link generation"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "key" in response.data # Check for key in response
|
||||
|
||||
# Verify the mock was called with the expected arguments
|
||||
mock_magic_link.assert_called_once()
|
||||
args = mock_magic_link.call_args[0]
|
||||
assert args[0] == "user@plane.so" # First arg should be the email
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
"""Test exceeding maximum magic link generation attempts"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
for _ in range(4):
|
||||
api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "error_code" in response.data # Check for error code in response
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestSignInEndpoint:
|
||||
"""Test sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for authentication tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with empty data"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with invalid email format"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "useremail.com", "password": "user@123"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_exists(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with non-existent user"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "user@email.so", "password": "user123"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_validity(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with incorrect password"""
|
||||
url = reverse("sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "password": "user123"}, follow=True)
|
||||
|
||||
# Check for the specific authentication error in the URL
|
||||
redirect_urls = [url for url, _ in response.redirect_chain]
|
||||
redirect_contents = " ".join(redirect_urls)
|
||||
|
||||
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
|
||||
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_login(self, django_client, setup_user, setup_instance):
|
||||
"""Test successful sign-in"""
|
||||
url = reverse("sign-in")
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(url, {"email": "user@plane.so", "password": "user@123"}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Now follow just the first redirect to avoid 404s
|
||||
response = django_client.get(response.url, follow=False)
|
||||
|
||||
# The user should be authenticated regardless of the final page
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_path_redirection(self, django_client, setup_user, setup_instance):
|
||||
"""Test sign-in with next_path parameter"""
|
||||
url = reverse("sign-in")
|
||||
next_path = "workspaces"
|
||||
|
||||
# First make the request without following redirects
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
# Instead, just verify that we're authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignIn:
|
||||
"""Test magic link sign-in functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_user(self, db):
|
||||
"""Create a test user for magic sign-in tests"""
|
||||
user = User.objects.create(email="user@plane.so")
|
||||
user.set_password("user@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with empty data"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
|
||||
"""Test magic link sign-in with expired/invalid link"""
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_does_not_exist(self, django_client, setup_instance):
|
||||
"""Test magic sign-in with non-existent user"""
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test successful magic link sign-in process"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(url, {"email": "user@plane.so", "code": token}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
"""Test magic sign-in with next_path parameter"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get("magic_user@plane.so"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
next_path = "workspaces"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token, "next_path": next_path},
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check that the redirect URL contains the next_path
|
||||
assert next_path in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestMagicSignUp:
|
||||
"""Test magic link sign-up functionality"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_without_data(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with empty data"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_already_exists(self, django_client, db, setup_instance):
|
||||
"""Test magic sign-up with existing user"""
|
||||
# Create a user that already exists
|
||||
User.objects.create(email="existing@plane.so")
|
||||
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True)
|
||||
|
||||
# Check redirect contains error code
|
||||
assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_instance):
|
||||
"""Test magic link sign-up with expired/invalid link"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False)
|
||||
|
||||
# Check that we get a redirect
|
||||
assert response.status_code == 302
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test successful magic link sign-up process"""
|
||||
email = "newuser@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(url, {"email": email, "code": token}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
"""Test magic sign-up with next_path parameter"""
|
||||
email = "newuser2@plane.so"
|
||||
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
response = api_client.post(gen_url, {"email": email}, format="json")
|
||||
|
||||
# Check that the token generation was successful
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Since we're mocking the magic_link task, we need to manually get the token from Redis
|
||||
ri = redis_instance()
|
||||
user_data = json.loads(ri.get(f"magic_{email}"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
next_path = "onboarding"
|
||||
response = django_client.post(url, {"email": email, "code": token, "next_path": next_path}, follow=False)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
|
||||
# Check if user was created
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
@@ -0,0 +1,524 @@
|
||||
# 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 rest_framework import status
|
||||
import uuid
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectUserProperty,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectBase:
|
||||
def get_project_url(self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False) -> str:
|
||||
"""
|
||||
Constructs the project endpoint URL for the given workspace as reverse() is
|
||||
unreliable due to duplicate 'name' values in URL patterns ('api' and 'app').
|
||||
|
||||
Args:
|
||||
workspace_slug (str): The slug of the workspace.
|
||||
pk (uuid.UUID, optional): The primary key of a specific project.
|
||||
details (bool, optional): If True, constructs the URL for the
|
||||
project details endpoint. Defaults to False.
|
||||
"""
|
||||
# Establish the common base URL for all project-related endpoints.
|
||||
base_url = f"/api/workspaces/{workspace_slug}/projects/"
|
||||
|
||||
# Specific project instance URL.
|
||||
if pk:
|
||||
return f"{base_url}{pk}/"
|
||||
|
||||
# Append 'details/' to the base URL.
|
||||
if details:
|
||||
return f"{base_url}details/"
|
||||
|
||||
# Return the base project list URL.
|
||||
return base_url
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIPost(TestProjectBase):
|
||||
"""Test project POST operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_empty_data(self, session_client, workspace):
|
||||
"""Test creating a project with empty data"""
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
# Test with empty data
|
||||
response = session_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_valid_data(self, session_client, workspace, create_user):
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
project_data = {
|
||||
"name": "New Project Test",
|
||||
"identifier": "NPT",
|
||||
}
|
||||
|
||||
user = create_user
|
||||
|
||||
# Make the request
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
# Check response status
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify project was created
|
||||
assert Project.objects.count() == 1
|
||||
project = Project.objects.get(name=project_data["name"])
|
||||
assert project.workspace == workspace
|
||||
|
||||
# Check if the member is created with the correct role
|
||||
assert ProjectMember.objects.count() == 1
|
||||
project_member = ProjectMember.objects.filter(project=project, member=user).first()
|
||||
assert project_member.role == 20 # Administrator
|
||||
assert project_member.is_active is True
|
||||
|
||||
# Verify ProjectUserProperty was created
|
||||
assert ProjectUserProperty.objects.filter(project=project, user=user).exists()
|
||||
|
||||
# Verify default states were created
|
||||
states = State.objects.filter(project=project)
|
||||
assert states.count() == 5
|
||||
expected_states = ["Backlog", "Todo", "In Progress", "Done", "Cancelled"]
|
||||
state_names = list(states.values_list("name", flat=True))
|
||||
assert set(state_names) == set(expected_states)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_project_lead(self, session_client, workspace, create_user):
|
||||
"""Test creating project with a different project lead"""
|
||||
# Create another user to be project lead
|
||||
project_lead = User.objects.create_user(email="lead@example.com", username="projectlead")
|
||||
|
||||
# Add project lead to workspace
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=project_lead, role=15)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Project with Lead",
|
||||
"identifier": "PWL",
|
||||
"project_lead": project_lead.id,
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify both creator and project lead are administrators
|
||||
project = Project.objects.get(name=project_data["name"])
|
||||
assert ProjectMember.objects.filter(project=project, role=20).count() == 2
|
||||
|
||||
# Verify both have ProjectUserProperty
|
||||
assert ProjectUserProperty.objects.filter(project=project).count() == 2
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_guest_forbidden(self, session_client, workspace):
|
||||
"""Test that guests cannot create projects"""
|
||||
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5)
|
||||
|
||||
session_client.force_authenticate(user=guest_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Guest Project",
|
||||
"identifier": "GP",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Project.objects.count() == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_unauthenticated(self, client, workspace):
|
||||
"""Test unauthenticated access"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Unauth Project",
|
||||
"identifier": "UP",
|
||||
}
|
||||
|
||||
response = client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_duplicate_name(self, session_client, workspace, create_user):
|
||||
"""Test creating project with duplicate name"""
|
||||
# Create first project
|
||||
Project.objects.create(name="Duplicate Name", identifier="DN1", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Duplicate Name",
|
||||
"identifier": "DN2",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_duplicate_identifier(self, session_client, workspace, create_user):
|
||||
"""Test creating project with duplicate identifier"""
|
||||
Project.objects.create(name="First Project", identifier="DUP", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Second Project",
|
||||
"identifier": "DUP",
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_missing_required_fields(self, session_client, workspace, create_user):
|
||||
"""Test validation with missing required fields"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
|
||||
# Test missing name
|
||||
response = session_client.post(url, {"identifier": "MN"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Test missing identifier
|
||||
response = session_client.post(url, {"name": "Missing Identifier"}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_all_optional_fields(self, session_client, workspace, create_user):
|
||||
"""Test creating project with all optional fields"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
project_data = {
|
||||
"name": "Full Project",
|
||||
"identifier": "FP",
|
||||
"description": "A comprehensive test project",
|
||||
"network": 2,
|
||||
"cycle_view": True,
|
||||
"issue_views_view": False,
|
||||
"module_view": True,
|
||||
"page_view": False,
|
||||
"inbox_view": True,
|
||||
"guest_view_all_features": True,
|
||||
"logo_props": {
|
||||
"in_use": "emoji",
|
||||
"emoji": {"value": "🚀", "unicode": "1f680"},
|
||||
},
|
||||
}
|
||||
|
||||
response = session_client.post(url, project_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response_data = response.json()
|
||||
assert response_data["description"] == project_data["description"]
|
||||
assert response_data["network"] == project_data["network"]
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIGet(TestProjectBase):
|
||||
"""Test project GET operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_authenticated_admin(self, session_client, workspace, create_user):
|
||||
"""Test listing projects as workspace admin"""
|
||||
# Create a project
|
||||
project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Test Project"
|
||||
assert data[0]["identifier"] == "TP"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_authenticated_guest(self, session_client, workspace):
|
||||
"""Test listing projects as workspace guest"""
|
||||
# Create a guest user
|
||||
guest_user = User.objects.create_user(email="guest@example.com", username="guest")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5, is_active=True)
|
||||
|
||||
# Create projects
|
||||
project1 = Project.objects.create(name="Project 1", identifier="P1", workspace=workspace)
|
||||
|
||||
Project.objects.create(name="Project 2", identifier="P2", workspace=workspace)
|
||||
|
||||
# Add guest to only one project
|
||||
ProjectMember.objects.create(project=project1, member=guest_user, role=10, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=guest_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
# Guest should only see projects they're members of
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Project 1"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_projects_unauthenticated(self, client, workspace):
|
||||
"""Test listing projects without authentication"""
|
||||
url = self.get_project_url(workspace.slug)
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_detail_projects(self, session_client, workspace, create_user):
|
||||
"""Test listing projects with detailed information"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Detailed Project",
|
||||
identifier="DP",
|
||||
workspace=workspace,
|
||||
description="A detailed test project",
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, details=True)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Detailed Project"
|
||||
assert data[0]["description"] == "A detailed test project"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_project_success(self, session_client, workspace, create_user):
|
||||
"""Test retrieving a specific project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Retrieve Test Project",
|
||||
identifier="RTP",
|
||||
workspace=workspace,
|
||||
description="Test project for retrieval",
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["name"] == "Retrieve Test Project"
|
||||
assert data["identifier"] == "RTP"
|
||||
assert data["description"] == "Test project for retrieval"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_project_not_found(self, session_client, workspace, create_user):
|
||||
"""Test retrieving a non-existent project"""
|
||||
fake_uuid = uuid.uuid4()
|
||||
url = self.get_project_url(workspace.slug, pk=fake_uuid)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_archived_project(self, session_client, workspace, create_user):
|
||||
"""Test retrieving an archived project"""
|
||||
# Create an archived project
|
||||
project = Project.objects.create(
|
||||
name="Archived Project",
|
||||
identifier="AP",
|
||||
workspace=workspace,
|
||||
archived_at=timezone.now(),
|
||||
)
|
||||
|
||||
# Add user as project member
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectAPIPatchDelete(TestProjectBase):
|
||||
"""Test project PATCH, and DELETE operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_project_success(self, session_client, workspace, create_user):
|
||||
"""Test successful partial update of project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(
|
||||
name="Original Project",
|
||||
identifier="OP",
|
||||
workspace=workspace,
|
||||
description="Original description",
|
||||
)
|
||||
|
||||
# Add user as project administrator
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {
|
||||
"name": "Updated Project",
|
||||
"description": "Updated description",
|
||||
"cycle_view": True,
|
||||
"module_view": False,
|
||||
}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify project was updated
|
||||
project.refresh_from_db()
|
||||
assert project.name == "Updated Project"
|
||||
assert project.description == "Updated description"
|
||||
assert project.cycle_view is True
|
||||
assert project.module_view is False
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_project_forbidden_non_admin(self, session_client, workspace):
|
||||
"""Test that non-admin project members cannot update project"""
|
||||
# Create a project
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
# Create a member user (not admin)
|
||||
member_user = User.objects.create_user(email="member@example.com", username="member")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
|
||||
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=member_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {"name": "Hacked Project"}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_duplicate_name_conflict(self, session_client, workspace, create_user):
|
||||
"""Test updating project with duplicate name returns conflict"""
|
||||
# Create two projects
|
||||
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
|
||||
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project2.id)
|
||||
update_data = {"name": "Project One"} # Duplicate name
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_duplicate_identifier_conflict(self, session_client, workspace, create_user):
|
||||
"""Test updating project with duplicate identifier returns conflict"""
|
||||
# Create two projects
|
||||
Project.objects.create(name="Project One", identifier="P1", workspace=workspace)
|
||||
project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project2.id)
|
||||
update_data = {"identifier": "P1"} # Duplicate identifier
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_partial_update_invalid_data(self, session_client, workspace, create_user):
|
||||
"""Test partial update with invalid data"""
|
||||
project = Project.objects.create(name="Valid Project", identifier="VP", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
update_data = {"name": ""}
|
||||
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_success_project_admin(self, session_client, workspace, create_user):
|
||||
"""Test successful project deletion by project admin"""
|
||||
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_success_workspace_admin(self, session_client, workspace):
|
||||
"""Test successful project deletion by workspace admin"""
|
||||
# Create workspace admin user
|
||||
workspace_admin = User.objects.create_user(email="admin@example.com", username="admin")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=workspace_admin, role=20, is_active=True)
|
||||
|
||||
project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace)
|
||||
|
||||
session_client.force_authenticate(user=workspace_admin)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_forbidden_non_admin(self, session_client, workspace):
|
||||
"""Test that non-admin users cannot delete projects"""
|
||||
# Create a member user (not admin)
|
||||
member_user = User.objects.create_user(email="member@example.com", username="member")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True)
|
||||
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
|
||||
|
||||
session_client.force_authenticate(user=member_user)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = session_client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Project.objects.filter(id=project.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_project_unauthenticated(self, client, workspace):
|
||||
"""Test unauthenticated project deletion"""
|
||||
project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace)
|
||||
|
||||
url = self.get_project_url(workspace.slug, pk=project.id)
|
||||
response = client.delete(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert Project.objects.filter(id=project.id).exists()
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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 django.urls import reverse
|
||||
from rest_framework import status
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestWorkspaceAPI:
|
||||
"""Test workspace CRUD operations"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_workspace_empty_data(self, session_client):
|
||||
"""Test creating a workspace with empty data"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Test with empty data
|
||||
response = session_client.post(url, {}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
|
||||
"""Test creating a workspace with valid data"""
|
||||
url = reverse("workspace")
|
||||
user = create_user # Use the create_user fixture directly as it returns a user object
|
||||
|
||||
# Test with valid data - include all required fields
|
||||
workspace_data = {
|
||||
"name": "Plane",
|
||||
"slug": "pla-ne-test",
|
||||
"company_name": "Plane Inc.",
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = session_client.post(url, workspace_data, format="json")
|
||||
|
||||
# Check response status
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify workspace was created
|
||||
assert Workspace.objects.count() == 1
|
||||
|
||||
# Check if the member is created
|
||||
assert WorkspaceMember.objects.count() == 1
|
||||
|
||||
# Check other values
|
||||
workspace = Workspace.objects.get(slug=workspace_data["slug"])
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first()
|
||||
assert workspace.owner == user
|
||||
assert workspace_member.role == 20
|
||||
|
||||
# Verify the workspace_seed task was called
|
||||
mock_workspace_seed.assert_called_once_with(response.data["id"])
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
|
||||
"""Test creating a duplicate workspace"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Create first workspace
|
||||
session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
|
||||
|
||||
# Try to create a workspace with the same slug
|
||||
response = session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
|
||||
|
||||
# The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Optionally check the error message to confirm it's related to the duplicate slug
|
||||
assert "slug" in response.data
|
||||
Reference in New Issue
Block a user