mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[WEB-3707] pytest based test suite for apiserver (#7010)
* pytest bases tests for apiserver * Trimmed spaces * Updated .gitignore for pytest local files
This commit is contained in:
parent
4e485d6402
commit
78cc32765b
2
.gitignore
vendored
2
.gitignore
vendored
@ -53,6 +53,8 @@ mediafiles
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
logs/
|
logs/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
assets/dist/
|
assets/dist/
|
||||||
|
|||||||
25
apiserver/.coveragerc
Normal file
25
apiserver/.coveragerc
Normal file
@ -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
|
||||||
@ -42,11 +42,11 @@ urlpatterns = [
|
|||||||
# credentials
|
# credentials
|
||||||
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
|
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
|
||||||
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
|
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
|
||||||
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
|
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
|
||||||
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
|
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
|
||||||
# signout
|
# signout
|
||||||
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
|
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
|
# csrf token
|
||||||
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
|
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
|
||||||
# Magic sign in
|
# Magic sign in
|
||||||
@ -56,17 +56,17 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"spaces/magic-generate/",
|
"spaces/magic-generate/",
|
||||||
MagicGenerateSpaceEndpoint.as_view(),
|
MagicGenerateSpaceEndpoint.as_view(),
|
||||||
name="magic-generate",
|
name="space-magic-generate",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"spaces/magic-sign-in/",
|
"spaces/magic-sign-in/",
|
||||||
MagicSignInSpaceEndpoint.as_view(),
|
MagicSignInSpaceEndpoint.as_view(),
|
||||||
name="magic-sign-in",
|
name="space-magic-sign-in",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"spaces/magic-sign-up/",
|
"spaces/magic-sign-up/",
|
||||||
MagicSignUpSpaceEndpoint.as_view(),
|
MagicSignUpSpaceEndpoint.as_view(),
|
||||||
name="magic-sign-up",
|
name="space-magic-sign-up",
|
||||||
),
|
),
|
||||||
## Google Oauth
|
## Google Oauth
|
||||||
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
|
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
|
||||||
@ -74,12 +74,12 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"spaces/google/",
|
"spaces/google/",
|
||||||
GoogleOauthInitiateSpaceEndpoint.as_view(),
|
GoogleOauthInitiateSpaceEndpoint.as_view(),
|
||||||
name="google-initiate",
|
name="space-google-initiate",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"google/callback/",
|
"spaces/google/callback/",
|
||||||
GoogleCallbackSpaceEndpoint.as_view(),
|
GoogleCallbackSpaceEndpoint.as_view(),
|
||||||
name="google-callback",
|
name="space-google-callback",
|
||||||
),
|
),
|
||||||
## Github Oauth
|
## Github Oauth
|
||||||
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
|
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
|
||||||
@ -87,12 +87,12 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"spaces/github/",
|
"spaces/github/",
|
||||||
GitHubOauthInitiateSpaceEndpoint.as_view(),
|
GitHubOauthInitiateSpaceEndpoint.as_view(),
|
||||||
name="github-initiate",
|
name="space-github-initiate",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"spaces/github/callback/",
|
"spaces/github/callback/",
|
||||||
GitHubCallbackSpaceEndpoint.as_view(),
|
GitHubCallbackSpaceEndpoint.as_view(),
|
||||||
name="github-callback",
|
name="space-github-callback",
|
||||||
),
|
),
|
||||||
## Gitlab Oauth
|
## Gitlab Oauth
|
||||||
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
|
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
|
||||||
@ -100,12 +100,12 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"spaces/gitlab/",
|
"spaces/gitlab/",
|
||||||
GitLabOauthInitiateSpaceEndpoint.as_view(),
|
GitLabOauthInitiateSpaceEndpoint.as_view(),
|
||||||
name="gitlab-initiate",
|
name="space-gitlab-initiate",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"spaces/gitlab/callback/",
|
"spaces/gitlab/callback/",
|
||||||
GitLabCallbackSpaceEndpoint.as_view(),
|
GitLabCallbackSpaceEndpoint.as_view(),
|
||||||
name="gitlab-callback",
|
name="space-gitlab-callback",
|
||||||
),
|
),
|
||||||
# Email Check
|
# Email Check
|
||||||
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
|
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
|
||||||
@ -120,12 +120,12 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"spaces/forgot-password/",
|
"spaces/forgot-password/",
|
||||||
ForgotPasswordSpaceEndpoint.as_view(),
|
ForgotPasswordSpaceEndpoint.as_view(),
|
||||||
name="forgot-password",
|
name="space-forgot-password",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"spaces/reset-password/<uidb64>/<token>/",
|
"spaces/reset-password/<uidb64>/<token>/",
|
||||||
ResetPasswordSpaceEndpoint.as_view(),
|
ResetPasswordSpaceEndpoint.as_view(),
|
||||||
name="forgot-password",
|
name="space-forgot-password",
|
||||||
),
|
),
|
||||||
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
|
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
|
||||||
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
|
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
|
||||||
|
|||||||
143
apiserver/plane/tests/README.md
Normal file
143
apiserver/plane/tests/README.md
Normal file
@ -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.
|
||||||
151
apiserver/plane/tests/TESTING_GUIDE.md
Normal file
151
apiserver/plane/tests/TESTING_GUIDE.md
Normal file
@ -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.
|
||||||
@ -1 +1 @@
|
|||||||
from .api import *
|
# Test package initialization
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Tests for File Asset Uploads
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Tests for ChangePassword and other Endpoints
|
|
||||||
@ -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")
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write Test for Cycle Endpoints
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write Test for Issue Endpoints
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Tests for OAuth Authentication Endpoint
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write Test for people Endpoint
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write Tests for project endpoints
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write Test for shortcuts
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Wrote test for state endpoints
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# TODO: Write test for view endpoints
|
|
||||||
@ -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)
|
|
||||||
78
apiserver/plane/tests/conftest.py
Normal file
78
apiserver/plane/tests/conftest.py
Normal file
@ -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
|
||||||
117
apiserver/plane/tests/conftest_external.py
Normal file
117
apiserver/plane/tests/conftest_external.py
Normal file
@ -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
|
||||||
0
apiserver/plane/tests/contract/api/__init__.py
Normal file
0
apiserver/plane/tests/contract/api/__init__.py
Normal file
1
apiserver/plane/tests/contract/app/__init__.py
Normal file
1
apiserver/plane/tests/contract/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
459
apiserver/plane/tests/contract/app/test_authentication.py
Normal file
459
apiserver/plane/tests/contract/app/test_authentication.py
Normal file
@ -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
|
||||||
79
apiserver/plane/tests/contract/app/test_workspace_app.py
Normal file
79
apiserver/plane/tests/contract/app/test_workspace_app.py
Normal file
@ -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
|
||||||
82
apiserver/plane/tests/factories.py
Normal file
82
apiserver/plane/tests/factories.py
Normal file
@ -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)
|
||||||
0
apiserver/plane/tests/smoke/__init__.py
Normal file
0
apiserver/plane/tests/smoke/__init__.py
Normal file
100
apiserver/plane/tests/smoke/test_auth_smoke.py
Normal file
100
apiserver/plane/tests/smoke/test_auth_smoke.py
Normal file
@ -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"
|
||||||
0
apiserver/plane/tests/unit/__init__.py
Normal file
0
apiserver/plane/tests/unit/__init__.py
Normal file
0
apiserver/plane/tests/unit/models/__init__.py
Normal file
0
apiserver/plane/tests/unit/models/__init__.py
Normal file
50
apiserver/plane/tests/unit/models/test_workspace_model.py
Normal file
50
apiserver/plane/tests/unit/models/test_workspace_model.py
Normal file
@ -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
|
||||||
0
apiserver/plane/tests/unit/serializers/__init__.py
Normal file
0
apiserver/plane/tests/unit/serializers/__init__.py
Normal file
71
apiserver/plane/tests/unit/serializers/test_workspace.py
Normal file
71
apiserver/plane/tests/unit/serializers/test_workspace.py
Normal file
@ -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"
|
||||||
0
apiserver/plane/tests/unit/utils/__init__.py
Normal file
0
apiserver/plane/tests/unit/utils/__init__.py
Normal file
49
apiserver/plane/tests/unit/utils/test_uuid.py
Normal file
49
apiserver/plane/tests/unit/utils/test_uuid.py
Normal file
@ -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)
|
||||||
17
apiserver/pytest.ini
Normal file
17
apiserver/pytest.ini
Normal file
@ -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
|
||||||
@ -1,4 +1,12 @@
|
|||||||
-r base.txt
|
-r base.txt
|
||||||
# test checker
|
# test framework
|
||||||
pytest==7.1.2
|
pytest==7.4.0
|
||||||
coverage==6.5.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
|
||||||
91
apiserver/run_tests.py
Executable file
91
apiserver/run_tests.py
Executable file
@ -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()
|
||||||
4
apiserver/run_tests.sh
Executable file
4
apiserver/run_tests.sh
Executable file
@ -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 "$@"
|
||||||
Loading…
Reference in New Issue
Block a user