+
{icon ? icon : }
From 4e485d6402d71ee263ddc507b7029d0b6b73e106 Mon Sep 17 00:00:00 2001
From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com>
Date: Mon, 26 May 2025 15:24:13 +0530
Subject: [PATCH 05/12] [WEB-4160] fix: close the context menu after select
#7113
---
web/core/components/workspace/sidebar/projects-list-item.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx
index 75b10aa4c79..715d02cb1c1 100644
--- a/web/core/components/workspace/sidebar/projects-list-item.tsx
+++ b/web/core/components/workspace/sidebar/projects-list-item.tsx
@@ -311,6 +311,7 @@ export const SidebarProjectsListItem: React.FC
= observer((props) => {
customButtonClassName="grid place-items-center"
placement="bottom-start"
useCaptureForOutsideClick
+ closeOnSelect
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
From 78cc32765bfb9ef4b5d7a6901d26332e2cd072eb Mon Sep 17 00:00:00 2001
From: Dheeraj Kumar Ketireddy
Date: Mon, 26 May 2025 15:26:26 +0530
Subject: [PATCH 06/12] [WEB-3707] pytest based test suite for apiserver
(#7010)
* pytest bases tests for apiserver
* Trimmed spaces
* Updated .gitignore for pytest local files
---
.gitignore | 2 +
apiserver/.coveragerc | 25 +
apiserver/plane/authentication/urls.py | 30 +-
apiserver/plane/tests/README.md | 143 ++++++
apiserver/plane/tests/TESTING_GUIDE.md | 151 ++++++
apiserver/plane/tests/__init__.py | 2 +-
apiserver/plane/tests/api/base.py | 34 --
apiserver/plane/tests/api/test_asset.py | 1 -
.../plane/tests/api/test_auth_extended.py | 1 -
.../plane/tests/api/test_authentication.py | 183 -------
apiserver/plane/tests/api/test_cycle.py | 1 -
apiserver/plane/tests/api/test_issue.py | 1 -
apiserver/plane/tests/api/test_oauth.py | 1 -
apiserver/plane/tests/api/test_people.py | 1 -
apiserver/plane/tests/api/test_project.py | 1 -
apiserver/plane/tests/api/test_shortcut.py | 1 -
apiserver/plane/tests/api/test_state.py | 1 -
apiserver/plane/tests/api/test_view.py | 1 -
apiserver/plane/tests/api/test_workspace.py | 44 --
apiserver/plane/tests/conftest.py | 78 +++
apiserver/plane/tests/conftest_external.py | 117 +++++
.../plane/tests/{api => contract}/__init__.py | 0
.../plane/tests/contract/api/__init__.py | 0
.../plane/tests/contract/app/__init__.py | 1 +
.../tests/contract/app/test_authentication.py | 459 ++++++++++++++++++
.../tests/contract/app/test_workspace_app.py | 79 +++
apiserver/plane/tests/factories.py | 82 ++++
apiserver/plane/tests/smoke/__init__.py | 0
.../plane/tests/smoke/test_auth_smoke.py | 100 ++++
apiserver/plane/tests/unit/__init__.py | 0
apiserver/plane/tests/unit/models/__init__.py | 0
.../tests/unit/models/test_workspace_model.py | 50 ++
.../plane/tests/unit/serializers/__init__.py | 0
.../tests/unit/serializers/test_workspace.py | 71 +++
apiserver/plane/tests/unit/utils/__init__.py | 0
apiserver/plane/tests/unit/utils/test_uuid.py | 49 ++
apiserver/pytest.ini | 17 +
apiserver/requirements/test.txt | 14 +-
apiserver/run_tests.py | 91 ++++
apiserver/run_tests.sh | 4 +
40 files changed, 1546 insertions(+), 290 deletions(-)
create mode 100644 apiserver/.coveragerc
create mode 100644 apiserver/plane/tests/README.md
create mode 100644 apiserver/plane/tests/TESTING_GUIDE.md
delete mode 100644 apiserver/plane/tests/api/base.py
delete mode 100644 apiserver/plane/tests/api/test_asset.py
delete mode 100644 apiserver/plane/tests/api/test_auth_extended.py
delete mode 100644 apiserver/plane/tests/api/test_authentication.py
delete mode 100644 apiserver/plane/tests/api/test_cycle.py
delete mode 100644 apiserver/plane/tests/api/test_issue.py
delete mode 100644 apiserver/plane/tests/api/test_oauth.py
delete mode 100644 apiserver/plane/tests/api/test_people.py
delete mode 100644 apiserver/plane/tests/api/test_project.py
delete mode 100644 apiserver/plane/tests/api/test_shortcut.py
delete mode 100644 apiserver/plane/tests/api/test_state.py
delete mode 100644 apiserver/plane/tests/api/test_view.py
delete mode 100644 apiserver/plane/tests/api/test_workspace.py
create mode 100644 apiserver/plane/tests/conftest.py
create mode 100644 apiserver/plane/tests/conftest_external.py
rename apiserver/plane/tests/{api => contract}/__init__.py (100%)
create mode 100644 apiserver/plane/tests/contract/api/__init__.py
create mode 100644 apiserver/plane/tests/contract/app/__init__.py
create mode 100644 apiserver/plane/tests/contract/app/test_authentication.py
create mode 100644 apiserver/plane/tests/contract/app/test_workspace_app.py
create mode 100644 apiserver/plane/tests/factories.py
create mode 100644 apiserver/plane/tests/smoke/__init__.py
create mode 100644 apiserver/plane/tests/smoke/test_auth_smoke.py
create mode 100644 apiserver/plane/tests/unit/__init__.py
create mode 100644 apiserver/plane/tests/unit/models/__init__.py
create mode 100644 apiserver/plane/tests/unit/models/test_workspace_model.py
create mode 100644 apiserver/plane/tests/unit/serializers/__init__.py
create mode 100644 apiserver/plane/tests/unit/serializers/test_workspace.py
create mode 100644 apiserver/plane/tests/unit/utils/__init__.py
create mode 100644 apiserver/plane/tests/unit/utils/test_uuid.py
create mode 100644 apiserver/pytest.ini
create mode 100755 apiserver/run_tests.py
create mode 100755 apiserver/run_tests.sh
diff --git a/.gitignore b/.gitignore
index 0c89564230e..a6a407ba9e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,8 @@ mediafiles
.env
.DS_Store
logs/
+htmlcov/
+.coverage
node_modules/
assets/dist/
diff --git a/apiserver/.coveragerc b/apiserver/.coveragerc
new file mode 100644
index 00000000000..bd829d14126
--- /dev/null
+++ b/apiserver/.coveragerc
@@ -0,0 +1,25 @@
+[run]
+source = plane
+omit =
+ */tests/*
+ */migrations/*
+ */settings/*
+ */wsgi.py
+ */asgi.py
+ */urls.py
+ manage.py
+ */admin.py
+ */apps.py
+
+[report]
+exclude_lines =
+ pragma: no cover
+ def __repr__
+ if self.debug:
+ raise NotImplementedError
+ if __name__ == .__main__.
+ pass
+ raise ImportError
+
+[html]
+directory = htmlcov
\ No newline at end of file
diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py
index d474fe4dfad..d8b5799de1a 100644
--- a/apiserver/plane/authentication/urls.py
+++ b/apiserver/plane/authentication/urls.py
@@ -42,11 +42,11 @@
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
- path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
- path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
+ path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
+ path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
- path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"),
+ path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
# csrf token
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
# Magic sign in
@@ -56,17 +56,17 @@
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
- name="magic-generate",
+ name="space-magic-generate",
),
path(
"spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(),
- name="magic-sign-in",
+ name="space-magic-sign-in",
),
path(
"spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(),
- name="magic-sign-up",
+ name="space-magic-sign-up",
),
## Google Oauth
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
@@ -74,12 +74,12 @@
path(
"spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(),
- name="google-initiate",
+ name="space-google-initiate",
),
path(
- "google/callback/",
+ "spaces/google/callback/",
GoogleCallbackSpaceEndpoint.as_view(),
- name="google-callback",
+ name="space-google-callback",
),
## Github Oauth
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
@@ -87,12 +87,12 @@
path(
"spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(),
- name="github-initiate",
+ name="space-github-initiate",
),
path(
"spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(),
- name="github-callback",
+ name="space-github-callback",
),
## Gitlab Oauth
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
@@ -100,12 +100,12 @@
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
- name="gitlab-initiate",
+ name="space-gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
- name="gitlab-callback",
+ name="space-gitlab-callback",
),
# Email Check
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
@@ -120,12 +120,12 @@
path(
"spaces/forgot-password/",
ForgotPasswordSpaceEndpoint.as_view(),
- name="forgot-password",
+ name="space-forgot-password",
),
path(
"spaces/reset-password///",
ResetPasswordSpaceEndpoint.as_view(),
- name="forgot-password",
+ name="space-forgot-password",
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
diff --git a/apiserver/plane/tests/README.md b/apiserver/plane/tests/README.md
new file mode 100644
index 00000000000..df9aba6da1d
--- /dev/null
+++ b/apiserver/plane/tests/README.md
@@ -0,0 +1,143 @@
+# Plane Tests
+
+This directory contains tests for the Plane application. The tests are organized using pytest.
+
+## Test Structure
+
+Tests are organized into the following categories:
+
+- **Unit tests**: Test individual functions or classes in isolation.
+- **Contract tests**: Test interactions between components and verify API contracts are fulfilled.
+ - **API tests**: Test the external API endpoints (under `/api/v1/`).
+ - **App tests**: Test the web application API endpoints (under `/api/`).
+- **Smoke tests**: Basic tests to verify that the application runs correctly.
+
+## API vs App Endpoints
+
+Plane has two types of API endpoints:
+
+1. **External API** (`plane.api`):
+ - Available at `/api/v1/` endpoint
+ - Uses API key authentication (X-Api-Key header)
+ - Designed for external API contracts and third-party access
+ - Tests use the `api_key_client` fixture for authentication
+ - Test files are in `contract/api/`
+
+2. **Web App API** (`plane.app`):
+ - Available at `/api/` endpoint
+ - Uses session-based authentication (CSRF disabled)
+ - Designed for the web application frontend
+ - Tests use the `session_client` fixture for authentication
+ - Test files are in `contract/app/`
+
+## Running Tests
+
+To run all tests:
+
+```bash
+python -m pytest
+```
+
+To run specific test categories:
+
+```bash
+# Run unit tests
+python -m pytest plane/tests/unit/
+
+# Run API contract tests
+python -m pytest plane/tests/contract/api/
+
+# Run App contract tests
+python -m pytest plane/tests/contract/app/
+
+# Run smoke tests
+python -m pytest plane/tests/smoke/
+```
+
+For convenience, we also provide a helper script:
+
+```bash
+# Run all tests
+./run_tests.py
+
+# Run only unit tests
+./run_tests.py -u
+
+# Run contract tests with coverage report
+./run_tests.py -c -o
+
+# Run tests in parallel
+./run_tests.py -p
+```
+
+## Fixtures
+
+The following fixtures are available for testing:
+
+- `api_client`: Unauthenticated API client
+- `create_user`: Creates a test user
+- `api_token`: API token for the test user
+- `api_key_client`: API client with API key authentication (for external API tests)
+- `session_client`: API client with session authentication (for app API tests)
+- `plane_server`: Live Django test server for HTTP-based smoke tests
+
+## Writing Tests
+
+When writing tests, follow these guidelines:
+
+1. Place tests in the appropriate directory based on their type.
+2. Use the correct client fixture based on the API being tested:
+ - For external API (`/api/v1/`), use `api_key_client`
+ - For web app API (`/api/`), use `session_client`
+ - For smoke tests with real HTTP, use `plane_server`
+3. Use the correct URL namespace when reverse-resolving URLs:
+ - For external API, use `reverse("api:endpoint_name")`
+ - For web app API, use `reverse("endpoint_name")`
+4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database.
+5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests.
+
+## Test Fixtures
+
+Common fixtures are defined in:
+
+- `conftest.py`: General fixtures for authentication, database access, etc.
+- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB)
+- `factories.py`: Test factories for easy model instance creation
+
+## Best Practices
+
+When writing tests, follow these guidelines:
+
+1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods.
+2. **Add markers to categorize tests**:
+ ```python
+ @pytest.mark.unit
+ @pytest.mark.contract
+ @pytest.mark.smoke
+ ```
+3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code.
+4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies.
+5. **Write focused tests** that verify one specific behavior or edge case.
+6. **Keep test files small and organized** by logical components or endpoints.
+7. **Target 90% code coverage** for models, serializers, and business logic.
+
+## External Dependencies
+
+Tests for components that interact with external services should:
+
+1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests.
+2. For more comprehensive contract tests, use Docker-based test containers (optional).
+
+## Coverage Reports
+
+Generate a coverage report with:
+
+```bash
+python -m pytest --cov=plane --cov-report=term --cov-report=html
+```
+
+This creates an HTML report in the `htmlcov/` directory.
+
+## Migration from Old Tests
+
+Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.
\ No newline at end of file
diff --git a/apiserver/plane/tests/TESTING_GUIDE.md b/apiserver/plane/tests/TESTING_GUIDE.md
new file mode 100644
index 00000000000..98f4a1dba7c
--- /dev/null
+++ b/apiserver/plane/tests/TESTING_GUIDE.md
@@ -0,0 +1,151 @@
+# Testing Guide for Plane
+
+This guide explains how to write tests for Plane using our pytest-based testing strategy.
+
+## Test Categories
+
+We divide tests into three categories:
+
+1. **Unit Tests**: Testing individual components in isolation.
+2. **Contract Tests**: Testing API endpoints and verifying contracts between components.
+3. **Smoke Tests**: Basic end-to-end tests for critical flows.
+
+## Writing Unit Tests
+
+Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing:
+
+- `tests/unit/models/` - For model tests
+- `tests/unit/serializers/` - For serializer tests
+- `tests/unit/utils/` - For utility function tests
+
+### Example Unit Test:
+
+```python
+import pytest
+from plane.api.serializers import MySerializer
+
+@pytest.mark.unit
+class TestMySerializer:
+ def test_serializer_valid_data(self):
+ # Create input data
+ data = {"field1": "value1", "field2": 42}
+
+ # Initialize the serializer
+ serializer = MySerializer(data=data)
+
+ # Validate
+ assert serializer.is_valid()
+
+ # Check validated data
+ assert serializer.validated_data["field1"] == "value1"
+ assert serializer.validated_data["field2"] == 42
+```
+
+## Writing Contract Tests
+
+Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints.
+
+### Example Contract Test:
+
+```python
+import pytest
+from django.urls import reverse
+from rest_framework import status
+
+@pytest.mark.contract
+class TestMyEndpoint:
+ @pytest.mark.django_db
+ def test_my_endpoint_get(self, auth_client):
+ # Get the URL
+ url = reverse("my-endpoint")
+
+ # Make request
+ response = auth_client.get(url)
+
+ # Check response
+ assert response.status_code == status.HTTP_200_OK
+ assert "data" in response.data
+```
+
+## Writing Smoke Tests
+
+Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server.
+
+### Example Smoke Test:
+
+```python
+import pytest
+import requests
+
+@pytest.mark.smoke
+class TestCriticalFlow:
+ @pytest.mark.django_db
+ def test_login_flow(self, plane_server, create_user, user_data):
+ # Get login URL
+ url = f"{plane_server.url}/api/auth/signin/"
+
+ # Test login
+ response = requests.post(
+ url,
+ json={
+ "email": user_data["email"],
+ "password": user_data["password"]
+ }
+ )
+
+ # Verify
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+```
+
+## Useful Fixtures
+
+Our test setup provides several useful fixtures:
+
+1. `api_client`: An unauthenticated DRF APIClient
+2. `api_key_client`: API client with API key authentication (for external API tests)
+3. `session_client`: API client with session authentication (for web app API tests)
+4. `create_user`: Creates and returns a test user
+5. `mock_redis`: Mocks Redis interactions
+6. `mock_elasticsearch`: Mocks Elasticsearch interactions
+7. `mock_celery`: Mocks Celery task execution
+
+## Using Factory Boy
+
+For more complex test data setup, use the provided factories:
+
+```python
+from plane.tests.factories import UserFactory, WorkspaceFactory
+
+# Create a user
+user = UserFactory()
+
+# Create a workspace with a specific owner
+workspace = WorkspaceFactory(owner=user)
+
+# Create multiple objects
+users = UserFactory.create_batch(5)
+```
+
+## Running Tests
+
+Use pytest to run tests:
+
+```bash
+# Run all tests
+python -m pytest
+
+# Run only unit tests with coverage
+python -m pytest -m unit --cov=plane
+```
+
+## Best Practices
+
+1. **Keep tests small and focused** - Each test should verify one specific behavior.
+2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.).
+3. **Mock external dependencies** - Use the provided mock fixtures.
+4. **Use factories** - For complex data setup, use factories.
+5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself.
+6. **Write readable assertions** - Use plain `assert` statements with clear messaging.
+7. **Focus on coverage** - Aim for ≥90% code coverage for critical components.
\ No newline at end of file
diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py
index 0a0e47b0b01..73d90cd21ba 100644
--- a/apiserver/plane/tests/__init__.py
+++ b/apiserver/plane/tests/__init__.py
@@ -1 +1 @@
-from .api import *
+# Test package initialization
diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py
deleted file mode 100644
index e3209a2818b..00000000000
--- a/apiserver/plane/tests/api/base.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# Third party imports
-from rest_framework.test import APITestCase, APIClient
-
-# Module imports
-from plane.db.models import User
-from plane.app.views.authentication import get_tokens_for_user
-
-
-class BaseAPITest(APITestCase):
- def setUp(self):
- self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10")
-
-
-class AuthenticatedAPITest(BaseAPITest):
- def setUp(self):
- super().setUp()
-
- ## Create Dummy User
- self.email = "user@plane.so"
- user = User.objects.create(email=self.email)
- user.set_password("user@123")
- user.save()
-
- # Set user
- self.user = user
-
- # Set Up User ID
- self.user_id = user.id
-
- access_token, _ = get_tokens_for_user(user)
- self.access_token = access_token
-
- # Set Up Authentication Token
- self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token)
diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py
deleted file mode 100644
index b15d32e40e4..00000000000
--- a/apiserver/plane/tests/api/test_asset.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Tests for File Asset Uploads
diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py
deleted file mode 100644
index af6450ef435..00000000000
--- a/apiserver/plane/tests/api/test_auth_extended.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Tests for ChangePassword and other Endpoints
diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py
deleted file mode 100644
index 5d7beabdfda..00000000000
--- a/apiserver/plane/tests/api/test_authentication.py
+++ /dev/null
@@ -1,183 +0,0 @@
-# Python import
-import json
-
-# Django imports
-from django.urls import reverse
-
-# Third Party imports
-from rest_framework import status
-from .base import BaseAPITest
-
-# Module imports
-from plane.db.models import User
-from plane.settings.redis import redis_instance
-
-
-class SignInEndpointTests(BaseAPITest):
- def setUp(self):
- super().setUp()
- user = User.objects.create(email="user@plane.so")
- user.set_password("user@123")
- user.save()
-
- def test_without_data(self):
- url = reverse("sign-in")
- response = self.client.post(url, {}, format="json")
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
- def test_email_validity(self):
- url = reverse("sign-in")
- response = self.client.post(
- url, {"email": "useremail.com", "password": "user@123"}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(
- response.data, {"error": "Please provide a valid email address."}
- )
-
- def test_password_validity(self):
- url = reverse("sign-in")
- response = self.client.post(
- url, {"email": "user@plane.so", "password": "user123"}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
- self.assertEqual(
- response.data,
- {
- "error": "Sorry, we could not find a user with the provided credentials. Please try again."
- },
- )
-
- def test_user_exists(self):
- url = reverse("sign-in")
- response = self.client.post(
- url, {"email": "user@email.so", "password": "user123"}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
- self.assertEqual(
- response.data,
- {
- "error": "Sorry, we could not find a user with the provided credentials. Please try again."
- },
- )
-
- def test_user_login(self):
- url = reverse("sign-in")
-
- response = self.client.post(
- url, {"email": "user@plane.so", "password": "user@123"}, format="json"
- )
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
-
-
-class MagicLinkGenerateEndpointTests(BaseAPITest):
- def setUp(self):
- super().setUp()
- user = User.objects.create(email="user@plane.so")
- user.set_password("user@123")
- user.save()
-
- def test_without_data(self):
- url = reverse("magic-generate")
- response = self.client.post(url, {}, format="json")
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
- def test_email_validity(self):
- url = reverse("magic-generate")
- response = self.client.post(url, {"email": "useremail.com"}, format="json")
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(
- response.data, {"error": "Please provide a valid email address."}
- )
-
- def test_magic_generate(self):
- url = reverse("magic-generate")
-
- ri = redis_instance()
- ri.delete("magic_user@plane.so")
-
- response = self.client.post(url, {"email": "user@plane.so"}, format="json")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- def test_max_generate_attempt(self):
- url = reverse("magic-generate")
-
- ri = redis_instance()
- ri.delete("magic_user@plane.so")
-
- for _ in range(4):
- response = self.client.post(url, {"email": "user@plane.so"}, format="json")
-
- response = self.client.post(url, {"email": "user@plane.so"}, format="json")
-
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(
- response.data, {"error": "Max attempts exhausted. Please try again later."}
- )
-
-
-class MagicSignInEndpointTests(BaseAPITest):
- def setUp(self):
- super().setUp()
- user = User.objects.create(email="user@plane.so")
- user.set_password("user@123")
- user.save()
-
- def test_without_data(self):
- url = reverse("magic-sign-in")
- response = self.client.post(url, {}, format="json")
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(response.data, {"error": "User token and key are required"})
-
- def test_expired_invalid_magic_link(self):
- ri = redis_instance()
- ri.delete("magic_user@plane.so")
-
- url = reverse("magic-sign-in")
- response = self.client.post(
- url,
- {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
- format="json",
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(
- response.data, {"error": "The magic code/link has expired please try again"}
- )
-
- def test_invalid_magic_code(self):
- ri = redis_instance()
- ri.delete("magic_user@plane.so")
- ## Create Token
- url = reverse("magic-generate")
- self.client.post(url, {"email": "user@plane.so"}, format="json")
-
- url = reverse("magic-sign-in")
- response = self.client.post(
- url,
- {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
- format="json",
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(
- response.data, {"error": "Your login code was incorrect. Please try again."}
- )
-
- def test_magic_code_sign_in(self):
- ri = redis_instance()
- ri.delete("magic_user@plane.so")
- ## Create Token
- url = reverse("magic-generate")
- self.client.post(url, {"email": "user@plane.so"}, format="json")
-
- # Get the token
- user_data = json.loads(ri.get("magic_user@plane.so"))
- token = user_data["token"]
-
- url = reverse("magic-sign-in")
- response = self.client.post(
- url, {"key": "magic_user@plane.so", "token": token}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py
deleted file mode 100644
index 72b580c99bb..00000000000
--- a/apiserver/plane/tests/api/test_cycle.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write Test for Cycle Endpoints
diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py
deleted file mode 100644
index a45ff36b1d1..00000000000
--- a/apiserver/plane/tests/api/test_issue.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write Test for Issue Endpoints
diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py
deleted file mode 100644
index 1e7dac0ef34..00000000000
--- a/apiserver/plane/tests/api/test_oauth.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Tests for OAuth Authentication Endpoint
diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py
deleted file mode 100644
index 624281a2ff1..00000000000
--- a/apiserver/plane/tests/api/test_people.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write Test for people Endpoint
diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py
deleted file mode 100644
index 9a7c50f1943..00000000000
--- a/apiserver/plane/tests/api/test_project.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write Tests for project endpoints
diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py
deleted file mode 100644
index 5103b505943..00000000000
--- a/apiserver/plane/tests/api/test_shortcut.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write Test for shortcuts
diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py
deleted file mode 100644
index a336d955af1..00000000000
--- a/apiserver/plane/tests/api/test_state.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Wrote test for state endpoints
diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py
deleted file mode 100644
index c8864f28ada..00000000000
--- a/apiserver/plane/tests/api/test_view.py
+++ /dev/null
@@ -1 +0,0 @@
-# TODO: Write test for view endpoints
diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py
deleted file mode 100644
index d63eab2e09d..00000000000
--- a/apiserver/plane/tests/api/test_workspace.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Django imports
-from django.urls import reverse
-
-# Third party import
-from rest_framework import status
-
-# Module imports
-from .base import AuthenticatedAPITest
-from plane.db.models import Workspace, WorkspaceMember
-
-
-class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
- def setUp(self):
- super().setUp()
-
- def test_create_workspace(self):
- url = reverse("workspace")
-
- # Test with empty data
- response = self.client.post(url, {}, format="json")
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
- # Test with valid data
- response = self.client.post(
- url, {"name": "Plane", "slug": "pla-ne"}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- self.assertEqual(Workspace.objects.count(), 1)
- # Check if the member is created
- self.assertEqual(WorkspaceMember.objects.count(), 1)
-
- # Check other values
- workspace = Workspace.objects.get(pk=response.data["id"])
- workspace_member = WorkspaceMember.objects.get(
- workspace=workspace, member_id=self.user_id
- )
- self.assertEqual(workspace.owner_id, self.user_id)
- self.assertEqual(workspace_member.role, 20)
-
- # Create a already existing workspace
- response = self.client.post(
- url, {"name": "Plane", "slug": "pla-ne"}, format="json"
- )
- self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
diff --git a/apiserver/plane/tests/conftest.py b/apiserver/plane/tests/conftest.py
new file mode 100644
index 00000000000..ce0d3be2b43
--- /dev/null
+++ b/apiserver/plane/tests/conftest.py
@@ -0,0 +1,78 @@
+import pytest
+from django.conf import settings
+from rest_framework.test import APIClient
+from pytest_django.fixtures import django_db_setup
+from unittest.mock import patch, MagicMock
+
+from plane.db.models import User
+from plane.db.models.api import APIToken
+
+
+@pytest.fixture(scope="session")
+def django_db_setup(django_db_setup):
+ """Set up the Django database for the test session"""
+ pass
+
+
+@pytest.fixture
+def api_client():
+ """Return an unauthenticated API client"""
+ return APIClient()
+
+
+@pytest.fixture
+def user_data():
+ """Return standard user data for tests"""
+ return {
+ "email": "test@plane.so",
+ "password": "test-password",
+ "first_name": "Test",
+ "last_name": "User"
+ }
+
+
+@pytest.fixture
+def create_user(db, user_data):
+ """Create and return a user instance"""
+ user = User.objects.create(
+ email=user_data["email"],
+ first_name=user_data["first_name"],
+ last_name=user_data["last_name"]
+ )
+ user.set_password(user_data["password"])
+ user.save()
+ return user
+
+
+@pytest.fixture
+def api_token(db, create_user):
+ """Create and return an API token for testing the external API"""
+ token = APIToken.objects.create(
+ user=create_user,
+ label="Test API Token",
+ token="test-api-token-12345",
+ )
+ return token
+
+
+@pytest.fixture
+def api_key_client(api_client, api_token):
+ """Return an API key authenticated client for external API testing"""
+ api_client.credentials(HTTP_X_API_KEY=api_token.token)
+ return api_client
+
+
+@pytest.fixture
+def session_client(api_client, create_user):
+ """Return a session authenticated API client for app API testing, which is what plane.app uses"""
+ api_client.force_authenticate(user=create_user)
+ return api_client
+
+
+@pytest.fixture
+def plane_server(live_server):
+ """
+ Renamed version of live_server fixture to avoid name clashes.
+ Returns a live Django server for testing HTTP requests.
+ """
+ return live_server
\ No newline at end of file
diff --git a/apiserver/plane/tests/conftest_external.py b/apiserver/plane/tests/conftest_external.py
new file mode 100644
index 00000000000..d2d6a2df51e
--- /dev/null
+++ b/apiserver/plane/tests/conftest_external.py
@@ -0,0 +1,117 @@
+import pytest
+from unittest.mock import MagicMock, patch
+from django.conf import settings
+
+
+@pytest.fixture
+def mock_redis():
+ """
+ Mock Redis for testing without actual Redis connection.
+
+ This fixture patches the redis_instance function to return a MagicMock
+ that behaves like a Redis client.
+ """
+ mock_redis_client = MagicMock()
+
+ # Configure the mock to handle common Redis operations
+ mock_redis_client.get.return_value = None
+ mock_redis_client.set.return_value = True
+ mock_redis_client.delete.return_value = True
+ mock_redis_client.exists.return_value = 0
+ mock_redis_client.ttl.return_value = -1
+
+ # Start the patch
+ with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client):
+ yield mock_redis_client
+
+
+@pytest.fixture
+def mock_elasticsearch():
+ """
+ Mock Elasticsearch for testing without actual ES connection.
+
+ This fixture patches Elasticsearch to return a MagicMock
+ that behaves like an Elasticsearch client.
+ """
+ mock_es_client = MagicMock()
+
+ # Configure the mock to handle common ES operations
+ mock_es_client.indices.exists.return_value = True
+ mock_es_client.indices.create.return_value = {"acknowledged": True}
+ mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}}
+ mock_es_client.index.return_value = {"_id": "test_id", "result": "created"}
+ mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"}
+ mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"}
+
+ # Start the patch
+ with patch('elasticsearch.Elasticsearch', return_value=mock_es_client):
+ yield mock_es_client
+
+
+@pytest.fixture
+def mock_mongodb():
+ """
+ Mock MongoDB for testing without actual MongoDB connection.
+
+ This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client.
+ """
+ # Create mock MongoDB clients and collections
+ mock_mongo_client = MagicMock()
+ mock_mongo_db = MagicMock()
+ mock_mongo_collection = MagicMock()
+
+ # Set up the chain: client -> database -> collection
+ mock_mongo_client.__getitem__.return_value = mock_mongo_db
+ mock_mongo_client.get_database.return_value = mock_mongo_db
+ mock_mongo_db.__getitem__.return_value = mock_mongo_collection
+
+ # Configure common MongoDB collection operations
+ mock_mongo_collection.find_one.return_value = None
+ mock_mongo_collection.find.return_value = MagicMock(
+ __iter__=lambda x: iter([]),
+ count=lambda: 0
+ )
+ mock_mongo_collection.insert_one.return_value = MagicMock(
+ inserted_id="mock_id_123",
+ acknowledged=True
+ )
+ mock_mongo_collection.insert_many.return_value = MagicMock(
+ inserted_ids=["mock_id_123", "mock_id_456"],
+ acknowledged=True
+ )
+ mock_mongo_collection.update_one.return_value = MagicMock(
+ modified_count=1,
+ matched_count=1,
+ acknowledged=True
+ )
+ mock_mongo_collection.update_many.return_value = MagicMock(
+ modified_count=2,
+ matched_count=2,
+ acknowledged=True
+ )
+ mock_mongo_collection.delete_one.return_value = MagicMock(
+ deleted_count=1,
+ acknowledged=True
+ )
+ mock_mongo_collection.delete_many.return_value = MagicMock(
+ deleted_count=2,
+ acknowledged=True
+ )
+ mock_mongo_collection.count_documents.return_value = 0
+
+ # Start the patch
+ with patch('pymongo.MongoClient', return_value=mock_mongo_client):
+ yield mock_mongo_client
+
+
+@pytest.fixture
+def mock_celery():
+ """
+ Mock Celery for testing without actual task execution.
+
+ This fixture patches Celery's task.delay() to prevent actual task execution.
+ """
+ # Start the patch
+ with patch('celery.app.task.Task.delay') as mock_delay:
+ mock_delay.return_value = MagicMock(id="mock-task-id")
+ yield mock_delay
\ No newline at end of file
diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/contract/__init__.py
similarity index 100%
rename from apiserver/plane/tests/api/__init__.py
rename to apiserver/plane/tests/contract/__init__.py
diff --git a/apiserver/plane/tests/contract/api/__init__.py b/apiserver/plane/tests/contract/api/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/contract/app/__init__.py b/apiserver/plane/tests/contract/app/__init__.py
new file mode 100644
index 00000000000..0519ecba6ea
--- /dev/null
+++ b/apiserver/plane/tests/contract/app/__init__.py
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apiserver/plane/tests/contract/app/test_authentication.py b/apiserver/plane/tests/contract/app/test_authentication.py
new file mode 100644
index 00000000000..0dc54871046
--- /dev/null
+++ b/apiserver/plane/tests/contract/app/test_authentication.py
@@ -0,0 +1,459 @@
+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, MagicMock
+
+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
\ No newline at end of file
diff --git a/apiserver/plane/tests/contract/app/test_workspace_app.py b/apiserver/plane/tests/contract/app/test_workspace_app.py
new file mode 100644
index 00000000000..71ad1d41243
--- /dev/null
+++ b/apiserver/plane/tests/contract/app/test_workspace_app.py
@@ -0,0 +1,79 @@
+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
\ No newline at end of file
diff --git a/apiserver/plane/tests/factories.py b/apiserver/plane/tests/factories.py
new file mode 100644
index 00000000000..8d95773ded1
--- /dev/null
+++ b/apiserver/plane/tests/factories.py
@@ -0,0 +1,82 @@
+import factory
+from uuid import uuid4
+from django.utils import timezone
+
+from plane.db.models import (
+ User,
+ Workspace,
+ WorkspaceMember,
+ Project,
+ ProjectMember
+)
+
+
+class UserFactory(factory.django.DjangoModelFactory):
+ """Factory for creating User instances"""
+ class Meta:
+ model = User
+ django_get_or_create = ('email',)
+
+ id = factory.LazyFunction(uuid4)
+ email = factory.Sequence(lambda n: f'user{n}@plane.so')
+ password = factory.PostGenerationMethodCall('set_password', 'password')
+ first_name = factory.Sequence(lambda n: f'First{n}')
+ last_name = factory.Sequence(lambda n: f'Last{n}')
+ is_active = True
+ is_superuser = False
+ is_staff = False
+
+
+class WorkspaceFactory(factory.django.DjangoModelFactory):
+ """Factory for creating Workspace instances"""
+ class Meta:
+ model = Workspace
+ django_get_or_create = ('slug',)
+
+ id = factory.LazyFunction(uuid4)
+ name = factory.Sequence(lambda n: f'Workspace {n}')
+ slug = factory.Sequence(lambda n: f'workspace-{n}')
+ owner = factory.SubFactory(UserFactory)
+ created_at = factory.LazyFunction(timezone.now)
+ updated_at = factory.LazyFunction(timezone.now)
+
+
+class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
+ """Factory for creating WorkspaceMember instances"""
+ class Meta:
+ model = WorkspaceMember
+
+ id = factory.LazyFunction(uuid4)
+ workspace = factory.SubFactory(WorkspaceFactory)
+ member = factory.SubFactory(UserFactory)
+ role = 20 # Admin role by default
+ created_at = factory.LazyFunction(timezone.now)
+ updated_at = factory.LazyFunction(timezone.now)
+
+
+class ProjectFactory(factory.django.DjangoModelFactory):
+ """Factory for creating Project instances"""
+ class Meta:
+ model = Project
+ django_get_or_create = ('name', 'workspace')
+
+ id = factory.LazyFunction(uuid4)
+ name = factory.Sequence(lambda n: f'Project {n}')
+ workspace = factory.SubFactory(WorkspaceFactory)
+ created_by = factory.SelfAttribute('workspace.owner')
+ updated_by = factory.SelfAttribute('workspace.owner')
+ created_at = factory.LazyFunction(timezone.now)
+ updated_at = factory.LazyFunction(timezone.now)
+
+
+class ProjectMemberFactory(factory.django.DjangoModelFactory):
+ """Factory for creating ProjectMember instances"""
+ class Meta:
+ model = ProjectMember
+
+ id = factory.LazyFunction(uuid4)
+ project = factory.SubFactory(ProjectFactory)
+ member = factory.SubFactory(UserFactory)
+ role = 20 # Admin role by default
+ created_at = factory.LazyFunction(timezone.now)
+ updated_at = factory.LazyFunction(timezone.now)
\ No newline at end of file
diff --git a/apiserver/plane/tests/smoke/__init__.py b/apiserver/plane/tests/smoke/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/smoke/test_auth_smoke.py b/apiserver/plane/tests/smoke/test_auth_smoke.py
new file mode 100644
index 00000000000..4d6de6c35c1
--- /dev/null
+++ b/apiserver/plane/tests/smoke/test_auth_smoke.py
@@ -0,0 +1,100 @@
+import pytest
+import requests
+from django.urls import reverse
+
+
+@pytest.mark.smoke
+class TestAuthSmoke:
+ """Smoke tests for authentication endpoints"""
+
+ @pytest.mark.django_db
+ def test_login_endpoint_available(self, plane_server, create_user, user_data):
+ """Test that the login endpoint is available and responds correctly"""
+ # Get the sign-in URL
+ relative_url = reverse("sign-in")
+ url = f"{plane_server.url}{relative_url}"
+
+ # 1. Test bad login - test with wrong password
+ response = requests.post(
+ url,
+ data={
+ "email": user_data["email"],
+ "password": "wrong-password"
+ }
+ )
+
+ # For bad credentials, any of these status codes would be valid
+ # The test shouldn't be brittle to minor implementation changes
+ assert response.status_code != 500, "Authentication should not cause server errors"
+ assert response.status_code != 404, "Authentication endpoint should exist"
+
+ if response.status_code == 200:
+ # If API returns 200 for failures, check the response body for error indication
+ if hasattr(response, 'json'):
+ try:
+ data = response.json()
+ # JSON response might indicate error in its structure
+ assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \
+ "Error response should contain error details"
+ except ValueError:
+ # It's ok if response isn't JSON format
+ pass
+ elif response.status_code in [302, 303]:
+ # If it's a redirect, it should redirect to a login page or error page
+ redirect_url = response.headers.get('Location', '')
+ assert "error" in redirect_url or "sign-in" in redirect_url, \
+ "Failed login should redirect to login page or error page"
+
+ # 2. Test good login with correct credentials
+ response = requests.post(
+ url,
+ data={
+ "email": user_data["email"],
+ "password": user_data["password"]
+ },
+ allow_redirects=False # Don't follow redirects
+ )
+
+ # Successful auth should not be a client error or server error
+ assert response.status_code not in range(400, 600), \
+ f"Authentication with valid credentials failed with status {response.status_code}"
+
+ # Specific validation based on response type
+ if response.status_code in [302, 303]:
+ # Redirect-based auth: check that redirect URL doesn't contain error
+ redirect_url = response.headers.get('Location', '')
+ assert "error" not in redirect_url and "error_code" not in redirect_url, \
+ "Successful login redirect should not contain error parameters"
+
+ elif response.status_code == 200:
+ # API token-based auth: check for tokens or user session
+ if hasattr(response, 'json'):
+ try:
+ data = response.json()
+ # If it's a token response
+ if "access_token" in data:
+ assert "refresh_token" in data, "JWT auth should return both access and refresh tokens"
+ # If it's a user session response
+ elif "user" in data:
+ assert "is_authenticated" in data and data["is_authenticated"], \
+ "User session response should indicate authentication"
+ # Otherwise it should at least indicate success
+ else:
+ assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \
+ "Success response should not contain error keys"
+ except ValueError:
+ # Non-JSON is acceptable if it's a redirect or HTML response
+ pass
+
+
+@pytest.mark.smoke
+class TestHealthCheckSmoke:
+ """Smoke test for health check endpoint"""
+
+ def test_healthcheck_endpoint(self, plane_server):
+ """Test that the health check endpoint is available and responds correctly"""
+ # Make a request to the health check endpoint
+ response = requests.get(f"{plane_server.url}/")
+
+ # Should be OK
+ assert response.status_code == 200, "Health check endpoint should return 200 OK"
\ No newline at end of file
diff --git a/apiserver/plane/tests/unit/__init__.py b/apiserver/plane/tests/unit/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/unit/models/__init__.py b/apiserver/plane/tests/unit/models/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/unit/models/test_workspace_model.py b/apiserver/plane/tests/unit/models/test_workspace_model.py
new file mode 100644
index 00000000000..40380fa0f47
--- /dev/null
+++ b/apiserver/plane/tests/unit/models/test_workspace_model.py
@@ -0,0 +1,50 @@
+import pytest
+from uuid import uuid4
+
+from plane.db.models import Workspace, WorkspaceMember, User
+
+
+@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
\ No newline at end of file
diff --git a/apiserver/plane/tests/unit/serializers/__init__.py b/apiserver/plane/tests/unit/serializers/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/unit/serializers/test_workspace.py b/apiserver/plane/tests/unit/serializers/test_workspace.py
new file mode 100644
index 00000000000..19767a7c61f
--- /dev/null
+++ b/apiserver/plane/tests/unit/serializers/test_workspace.py
@@ -0,0 +1,71 @@
+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"
\ No newline at end of file
diff --git a/apiserver/plane/tests/unit/utils/__init__.py b/apiserver/plane/tests/unit/utils/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apiserver/plane/tests/unit/utils/test_uuid.py b/apiserver/plane/tests/unit/utils/test_uuid.py
new file mode 100644
index 00000000000..81403c5bef2
--- /dev/null
+++ b/apiserver/plane/tests/unit/utils/test_uuid.py
@@ -0,0 +1,49 @@
+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)
\ No newline at end of file
diff --git a/apiserver/pytest.ini b/apiserver/pytest.ini
new file mode 100644
index 00000000000..e2f19445677
--- /dev/null
+++ b/apiserver/pytest.ini
@@ -0,0 +1,17 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = plane.settings.test
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+
+markers =
+ unit: Unit tests for models, serializers, and utility functions
+ contract: Contract tests for API endpoints
+ smoke: Smoke tests for critical functionality
+ slow: Tests that are slow and might be skipped in some contexts
+
+addopts =
+ --strict-markers
+ --reuse-db
+ --nomigrations
+ -vs
\ No newline at end of file
diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt
index 1ffc82d006e..85978128b92 100644
--- a/apiserver/requirements/test.txt
+++ b/apiserver/requirements/test.txt
@@ -1,4 +1,12 @@
-r base.txt
-# test checker
-pytest==7.1.2
-coverage==6.5.0
\ No newline at end of file
+# test framework
+pytest==7.4.0
+pytest-django==4.5.2
+pytest-cov==4.1.0
+pytest-xdist==3.3.1
+pytest-mock==3.11.1
+factory-boy==3.3.0
+freezegun==1.2.2
+coverage==7.2.7
+httpx==0.24.1
+requests==2.31.0
\ No newline at end of file
diff --git a/apiserver/run_tests.py b/apiserver/run_tests.py
new file mode 100755
index 00000000000..f4f0951b199
--- /dev/null
+++ b/apiserver/run_tests.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+import argparse
+import subprocess
+import sys
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run Plane tests")
+ parser.add_argument(
+ "-u", "--unit",
+ action="store_true",
+ help="Run unit tests only"
+ )
+ parser.add_argument(
+ "-c", "--contract",
+ action="store_true",
+ help="Run contract tests only"
+ )
+ parser.add_argument(
+ "-s", "--smoke",
+ action="store_true",
+ help="Run smoke tests only"
+ )
+ parser.add_argument(
+ "-o", "--coverage",
+ action="store_true",
+ help="Generate coverage report"
+ )
+ parser.add_argument(
+ "-p", "--parallel",
+ action="store_true",
+ help="Run tests in parallel"
+ )
+ parser.add_argument(
+ "-v", "--verbose",
+ action="store_true",
+ help="Verbose output"
+ )
+ args = parser.parse_args()
+
+ # Build command
+ cmd = ["python", "-m", "pytest"]
+ markers = []
+
+ # Add test markers
+ if args.unit:
+ markers.append("unit")
+ if args.contract:
+ markers.append("contract")
+ if args.smoke:
+ markers.append("smoke")
+
+ # Add markers filter
+ if markers:
+ cmd.extend(["-m", " or ".join(markers)])
+
+ # Add coverage
+ if args.coverage:
+ cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
+
+ # Add parallel
+ if args.parallel:
+ cmd.extend(["-n", "auto"])
+
+ # Add verbose
+ if args.verbose:
+ cmd.append("-v")
+
+ # Add common flags
+ cmd.extend(["--reuse-db", "--nomigrations"])
+
+ # Print command
+ print(f"Running: {' '.join(cmd)}")
+
+ # Execute command
+ result = subprocess.run(cmd)
+
+ # Check coverage thresholds if coverage is enabled
+ if args.coverage:
+ print("Checking coverage thresholds...")
+ coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"]
+ coverage_result = subprocess.run(coverage_cmd)
+ if coverage_result.returncode != 0:
+ print("Coverage below threshold (90%)")
+ sys.exit(coverage_result.returncode)
+
+ sys.exit(result.returncode)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/apiserver/run_tests.sh b/apiserver/run_tests.sh
new file mode 100755
index 00000000000..7e22479b57e
--- /dev/null
+++ b/apiserver/run_tests.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+# This is a simple wrapper script that calls the main test runner in the tests directory
+exec tests/run_tests.sh "$@"
\ No newline at end of file
From 04c7c53e0926910aa2dae2b68348794a12ba742f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 26 May 2025 19:45:15 +0530
Subject: [PATCH 07/12] chore(deps): bump requests (#7120)
Bumps the pip group with 1 update in the /apiserver/requirements directory: [requests](https://github.com/psf/requests).
Updates `requests` from 2.31.0 to 2.32.2
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.32.2)
---
updated-dependencies:
- dependency-name: requests
dependency-version: 2.32.2
dependency-type: direct:production
dependency-group: pip
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
apiserver/requirements/test.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt
index 85978128b92..9536ab1e276 100644
--- a/apiserver/requirements/test.txt
+++ b/apiserver/requirements/test.txt
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
-requests==2.31.0
\ No newline at end of file
+requests==2.32.2
\ No newline at end of file
From 83128c24a9b894779c70b099349297dfbe4276b3 Mon Sep 17 00:00:00 2001
From: JayashTripathy
Date: Mon, 26 May 2025 20:31:35 +0530
Subject: [PATCH 08/12] chore: added favicon and title of links
---
.../issues/issue-detail/links/link-item.tsx | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/web/core/components/issues/issue-detail/links/link-item.tsx b/web/core/components/issues/issue-detail/links/link-item.tsx
index edb45bb3b8c..ebdba202d9c 100644
--- a/web/core/components/issues/issue-detail/links/link-item.tsx
+++ b/web/core/components/issues/issue-detail/links/link-item.tsx
@@ -38,6 +38,8 @@ export const IssueLinkItem: FC = observer((props) => {
if (!linkDetail) return <>>;
const Icon = getIconForLink(linkDetail.url);
+ const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
+ const linkTitle: string | undefined = linkDetail.metadata?.title;
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
@@ -50,15 +52,21 @@ export const IssueLinkItem: FC = observer((props) => {
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
>
From 64165695bb88906c7aeb002f89b3db2a6cff5f52 Mon Sep 17 00:00:00 2001
From: sangeethailango
Date: Tue, 27 May 2025 18:59:36 +0530
Subject: [PATCH 09/12] fix: return project joining date
---
apiserver/plane/app/serializers/project.py | 2 +-
apiserver/plane/app/views/project/member.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py
index 73c8a85d973..fa58ea49915 100644
--- a/apiserver/plane/app/serializers/project.py
+++ b/apiserver/plane/app/serializers/project.py
@@ -151,7 +151,7 @@ class Meta:
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
- fields = ("id", "role", "member", "project")
+ fields = ("id", "role", "member", "project", "created_at")
class ProjectMemberInviteSerializer(BaseSerializer):
diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py
index 7b910509c41..b2e8a6e3c89 100644
--- a/apiserver/plane/app/views/project/member.py
+++ b/apiserver/plane/app/views/project/member.py
@@ -171,7 +171,7 @@ def list(self, request, slug, project_id):
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
- project_members, fields=("id", "member", "role"), many=True
+ project_members, fields=("id", "member", "role", "created_at"), many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
From 0f82be1bdd088e0a09aa125eeb0e3cca925d6efc Mon Sep 17 00:00:00 2001
From: gakshita
Date: Tue, 27 May 2025 19:27:36 +0530
Subject: [PATCH 10/12] fix: added project's joining date
---
packages/types/src/project/projects.d.ts | 1 +
packages/types/src/users.d.ts | 1 +
web/core/store/member/project-member.store.ts | 5 ++++-
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts
index e1d9117a1be..d83853bb6e8 100644
--- a/packages/types/src/project/projects.d.ts
+++ b/packages/types/src/project/projects.d.ts
@@ -114,6 +114,7 @@ export interface IProjectMembership {
id: string;
member: string;
role: TUserPermissions;
+ created_at: string;
}
export interface IProjectBulkAddFormData {
diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts
index 9f6ac490559..eda9a022af2 100644
--- a/packages/types/src/users.d.ts
+++ b/packages/types/src/users.d.ts
@@ -12,6 +12,7 @@ export interface IUserLite {
id: string;
is_bot: boolean;
last_name: string;
+ joining_date?: string;
}
export interface IUser extends IUserLite {
// only for uploading the cover image
diff --git a/web/core/store/member/project-member.store.ts b/web/core/store/member/project-member.store.ts
index e97e5ab320d..ad9b1252a75 100644
--- a/web/core/store/member/project-member.store.ts
+++ b/web/core/store/member/project-member.store.ts
@@ -127,7 +127,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
- member: this.memberRoot?.memberMap?.[projectMember.member],
+ member: {
+ ...this.memberRoot?.memberMap?.[projectMember.member],
+ joining_date: projectMember.created_at,
+ },
};
return memberDetails;
});
From 9c83fbfc580ff4c316e1cac0fe935ebcd64f7804 Mon Sep 17 00:00:00 2001
From: sangeethailango
Date: Wed, 28 May 2025 16:42:24 +0530
Subject: [PATCH 11/12] fix: set created_at as read_only_fields
---
apiserver/plane/app/serializers/project.py | 1 +
apiserver/plane/app/views/project/member.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py
index fa58ea49915..62263855895 100644
--- a/apiserver/plane/app/serializers/project.py
+++ b/apiserver/plane/app/serializers/project.py
@@ -152,6 +152,7 @@ class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "created_at")
+ read_only_fields = ["created_at"]
class ProjectMemberInviteSerializer(BaseSerializer):
diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py
index b2e8a6e3c89..7b910509c41 100644
--- a/apiserver/plane/app/views/project/member.py
+++ b/apiserver/plane/app/views/project/member.py
@@ -171,7 +171,7 @@ def list(self, request, slug, project_id):
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
- project_members, fields=("id", "member", "role", "created_at"), many=True
+ project_members, fields=("id", "member", "role"), many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
From 87de8315597207d2f4381214c77006e19d41c63e Mon Sep 17 00:00:00 2001
From: JayashTripathy
Date: Wed, 28 May 2025 19:37:03 +0530
Subject: [PATCH 12/12] feat: add Link icon to issue link item and update
favicon handling
---
web/core/components/issues/issue-detail/links/link-item.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/web/core/components/issues/issue-detail/links/link-item.tsx b/web/core/components/issues/issue-detail/links/link-item.tsx
index ebdba202d9c..83ddc4a7df9 100644
--- a/web/core/components/issues/issue-detail/links/link-item.tsx
+++ b/web/core/components/issues/issue-detail/links/link-item.tsx
@@ -2,7 +2,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
-import { Pencil, Trash2, Copy } from "lucide-react";
+import { Pencil, Trash2, Copy, Link } from "lucide-react";
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssueServiceType } from "@plane/types";
@@ -37,7 +37,7 @@ export const IssueLinkItem: FC = observer((props) => {
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <>>;
- const Icon = getIconForLink(linkDetail.url);
+ // const Icon = getIconForLink(linkDetail.url);
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
const linkTitle: string | undefined = linkDetail.metadata?.title;
@@ -55,7 +55,7 @@ export const IssueLinkItem: FC = observer((props) => {
{faviconUrl ? (
) : (
-
+
)}