diff --git a/example_project/conftest.py b/example_project/conftest.py new file mode 100644 index 0000000..6e8a297 --- /dev/null +++ b/example_project/conftest.py @@ -0,0 +1,92 @@ +"""Pytest configuration for example project.""" + +from pathlib import Path + +import pytest +from django.conf import settings +from django.db import connection +from django.db.migrations.recorder import MigrationRecorder + + +class MigrationTracker: + """Tracks migration files created during tests.""" + + def __init__(self): + self.initial_migrations = set() + + def snapshot_migrations(self): + """Take a snapshot of existing migration files.""" + self.initial_migrations.clear() + for app_config in settings.INSTALLED_APPS: + if "." in app_config: + app_name = app_config.split(".")[-1] + migrations_dir = Path(settings.BASE_DIR) / "example_project" / app_name / "migrations" + if migrations_dir.exists(): + self.initial_migrations.update(str(f.absolute()) for f in migrations_dir.glob("[0-9]*.py")) + + def get_new_migrations(self): + """Get list of migration files created since last snapshot.""" + current_migrations = set() + for app_config in settings.INSTALLED_APPS: + if "." in app_config: + app_name = app_config.split(".")[-1] + migrations_dir = Path(settings.BASE_DIR) / "example_project" / app_name / "migrations" + if migrations_dir.exists(): + current_migrations.update(str(f.absolute()) for f in migrations_dir.glob("[0-9]*.py")) + return current_migrations - self.initial_migrations + + +# Create a single instance to use across tests +migration_tracker = MigrationTracker() + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup, django_db_blocker): + """Setup database for testing and handle migration cleanup.""" + with django_db_blocker.unblock(): + # Store initial migration state + initial_db_migrations = set( + (migration.app, migration.name) for migration in MigrationRecorder.Migration.objects.all() + ) + + # Take snapshot of migration files + migration_tracker.snapshot_migrations() + + yield + + # Clean up migrations created during testing + with connection.cursor() as cursor: + # Get current migrations + final_migrations = set( + (migration.app, migration.name) for migration in MigrationRecorder.Migration.objects.all() + ) + + # Find and remove new migrations from database + new_migrations = final_migrations - initial_db_migrations + for app, name in new_migrations: + cursor.execute("DELETE FROM django_migrations WHERE app = %s AND name = %s", [app, name]) + + # Clean up new migration files + new_migration_files = migration_tracker.get_new_migrations() + for migration_file in new_migration_files: + try: + Path(migration_file).unlink() + except FileNotFoundError: + pass # File was already deleted + + +@pytest.fixture(autouse=True) +def clean_test_migrations(): + """Track and clean migrations for each test.""" + # Take snapshot before test + migration_tracker.snapshot_migrations() + + yield + + # Clean up new migration files after test + new_migration_files = migration_tracker.get_new_migrations() + for migration_file in new_migration_files: + try: + Path(migration_file).unlink() + except FileNotFoundError: + pass diff --git a/example_project/test_commands.py b/example_project/test_commands.py new file mode 100644 index 0000000..4d00053 --- /dev/null +++ b/example_project/test_commands.py @@ -0,0 +1,34 @@ +"""Test cases for management commands with migration cleanup.""" + +from io import StringIO + +import pytest +from django.core.management import call_command + + +@pytest.mark.django_db +class TestManagementCommands: + """Test cases for the management commands.""" + + def test_listoptions_command(self): + """Test the listoptions command.""" + out = StringIO() + call_command("listoptions", stdout=out) + output = out.getvalue() + assert "Model: TaskPriorityOption" in output + assert "Model: TaskStatusOption" in output + + def test_syncoptions_command(self): + """Test the syncoptions command.""" + out = StringIO() + call_command("syncoptions", stdout=out) + output = out.getvalue() + assert "Model: TaskPriorityOption" in output + assert "Model: TaskStatusOption" in output + + def test_maketriggers_command(self): + """Test the maketriggers command.""" + out = StringIO() + call_command("maketriggers", stdout=out) + output = out.getvalue() + assert "Creating migration for taskpriorityselection" in output diff --git a/example_project/test_django_option_lists.py b/example_project/test_django_option_lists.py new file mode 100644 index 0000000..952ef63 --- /dev/null +++ b/example_project/test_django_option_lists.py @@ -0,0 +1,19 @@ +"""Test cases for the django-tenant-options package.""" + +from django.apps import apps +from django.conf import settings + + +def test_succeeds() -> None: + """It exits with a status code of zero.""" + assert 0 == 0 + + +def test_settings() -> None: + """It exits with a status code of zero.""" + assert settings.USE_TZ is True + + +def test_apps() -> None: + """It exits with a status code of zero.""" + assert "django_tenant_options" in apps.get_app_config("django_tenant_options").name diff --git a/example_project/test_forms.py b/example_project/test_forms.py new file mode 100644 index 0000000..c00cfef --- /dev/null +++ b/example_project/test_forms.py @@ -0,0 +1,524 @@ +"""Test cases for forms in the example project.""" + +import random + +import pytest +from django import forms +from django.contrib.auth import get_user_model + +from django_tenant_options.choices import OptionType +from django_tenant_options.exceptions import NoTenantProvidedFromViewError +from django_tenant_options.forms import OptionCreateFormMixin +from django_tenant_options.forms import OptionUpdateFormMixin +from django_tenant_options.forms import SelectionsForm +from django_tenant_options.forms import TenantFormBaseMixin +from django_tenant_options.forms import UserFacingFormMixin +from example_project.example.models import Task +from example_project.example.models import TaskPriorityOption +from example_project.example.models import TaskPrioritySelection +from example_project.example.models import TaskStatusOption +from example_project.example.models import Tenant + + +User = get_user_model() + + +@pytest.mark.django_db +class TestTenantFormBaseMixin: + """Test cases for TenantFormBaseMixin.""" + + class TestForm(TenantFormBaseMixin, forms.ModelForm): + """Test form for TenantFormBaseMixin.""" + + __test__ = False + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = ["name", "tenant"] + + def test_no_tenant_provided(self): + """Test form raises error when no tenant provided.""" + with pytest.raises(NoTenantProvidedFromViewError): + self.TestForm() + + def test_tenant_field_hidden(self): + """Test tenant field is hidden in form.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestForm(tenant=tenant) + assert isinstance(form.fields["tenant"].widget, forms.HiddenInput) + assert form.fields["tenant"].initial == tenant + + def test_clean_tenant(self): + """Test clean method enforces correct tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + other_tenant = Tenant.objects.create(name="Other Tenant", subdomain="other-tenant") + + form = self.TestForm( + tenant=tenant, data={"name": "Test Option", "tenant": other_tenant.id} # Try to submit different tenant + ) + + assert form.is_valid() + assert form.cleaned_data["tenant"] == tenant # Should enforce original tenant + + def test_associated_tenants_field_removal(self): + """Test associated_tenants field is removed from form.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + class FormWithAssociatedTenants(TenantFormBaseMixin, forms.ModelForm): + """Form with associated_tenants field for testing removal.""" + + associated_tenants = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all()) + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = ["name", "tenant", "associated_tenants"] + + form = FormWithAssociatedTenants(tenant=tenant) + assert "associated_tenants" not in form.fields + + def test_clean_with_invalid_data(self): + """Test clean method with invalid form data.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + class FormWithRequiredField(TenantFormBaseMixin, forms.ModelForm): + """Form with required field for testing clean method.""" + + required_field = forms.CharField(required=True) + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = ["name", "tenant", "required_field"] + + form = FormWithRequiredField(tenant=tenant, data={}) + assert not form.is_valid() + assert "required_field" in form.errors + assert "tenant" not in form.errors # Tenant should still be valid + + +@pytest.mark.django_db +class TestOptionCreateFormMixin: + """Test cases for OptionCreateFormMixin.""" + + class TestCreateForm(OptionCreateFormMixin, forms.ModelForm): + """Test form for OptionCreateFormMixin.""" + + __test__ = False + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = "__all__" + + def test_option_type_hidden_and_custom(self): + """Test option_type is hidden and set to CUSTOM.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestCreateForm(tenant=tenant) + + assert isinstance(form.fields["option_type"].widget, forms.HiddenInput) + assert form.fields["option_type"].initial == OptionType.CUSTOM + + def test_deleted_field_hidden_and_none(self): + """Test deleted field is hidden and set to None.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestCreateForm(tenant=tenant) + + assert isinstance(form.fields["deleted"].widget, forms.HiddenInput) + assert form.fields["deleted"].initial is None + + def test_create_option(self): + """Test creating a custom option.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestCreateForm( + tenant=tenant, + data={"name": "Custom Option", "option_type": OptionType.CUSTOM, "tenant": tenant.id, "deleted": None}, + ) + + assert form.is_valid() + option = form.save() + assert option.name == "Custom Option" + assert option.option_type == OptionType.CUSTOM + assert option.tenant == tenant + assert option.deleted is None + + def test_inheritance_chain(self): + """Test form works with multiple inheritance.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + class CustomMixin: + """Custom mixin for form.""" + + def clean_name(self, *args, **kwargs): + """Custom clean method for name field.""" + name = self.cleaned_data.get("name", "") + return name.upper() + + class ComplexCreateForm(CustomMixin, OptionCreateFormMixin, forms.ModelForm): + """Complex form for testing multiple inheritance.""" + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = "__all__" + + form = ComplexCreateForm( + tenant=tenant, + data={"name": "test option", "option_type": OptionType.CUSTOM, "tenant": tenant.id, "deleted": None}, + ) + + assert form.is_valid() + option = form.save() + assert option.name == "TEST OPTION" # Verify CustomMixin.clean_name was called + + def test_attempt_non_custom_option_type(self): + """Test attempt to create option with non-custom option type.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestCreateForm( + tenant=tenant, + data={ + "name": "Test Option", + "option_type": OptionType.MANDATORY, # Try to create mandatory option + "tenant": tenant.id, + "deleted": None, + }, + ) + + assert form.is_valid() + option = form.save() + assert option.option_type == OptionType.CUSTOM # Should force CUSTOM type + + +@pytest.mark.django_db +class TestOptionUpdateFormMixin: + """Test cases for OptionUpdateFormMixin.""" + + class TestUpdateForm(OptionUpdateFormMixin, forms.ModelForm): + """Test form for OptionUpdateFormMixin.""" + + __test__ = False + + class Meta: + """Meta class for form.""" + + model = TaskPriorityOption + fields = "__all__" + + def test_delete_field_added(self): + """Test delete checkbox field is added.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestUpdateForm(tenant=tenant) + assert "delete" in form.fields + assert isinstance(form.fields["delete"], forms.BooleanField) + assert form.fields["delete"].required is False + + def test_update_without_delete(self): + """Test updating option without deletion.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Original Name", option_type=OptionType.CUSTOM, tenant=tenant) + + form = self.TestUpdateForm( + tenant=tenant, + instance=option, + data={ + "name": "Updated Name", + "option_type": OptionType.CUSTOM, + "tenant": tenant.id, + "deleted": None, + "delete": False, + }, + ) + + assert form.is_valid() + updated_option = form.save() + assert updated_option.name == "Updated Name" + assert updated_option.deleted is None + + def test_update_with_delete(self): + """Test updating option with deletion.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="To Delete", option_type=OptionType.CUSTOM, tenant=tenant) + + form = self.TestUpdateForm( + tenant=tenant, + instance=option, + data={ + "name": "To Delete", + "option_type": OptionType.CUSTOM, + "tenant": tenant.id, + "deleted": None, + "delete": True, + }, + ) + + assert form.is_valid() + updated_option = form.save() + assert updated_option.deleted is not None + + def test_partial_update(self): + """Test partial update of option fields.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Original Name", option_type=OptionType.CUSTOM, tenant=tenant) + + # Only update name field + form = self.TestUpdateForm( + tenant=tenant, + instance=option, + data={ + "name": "Updated Name", + "option_type": OptionType.CUSTOM, + "tenant": tenant.id, + }, + ) + + assert form.is_valid() + updated_option = form.save() + assert updated_option.name == "Updated Name" + assert updated_option.option_type == OptionType.CUSTOM + assert updated_option.tenant == tenant + + def test_attempted_tenant_change(self): + """Test attempt to change option's tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + other_tenant = Tenant.objects.create(name="Other Tenant", subdomain="other-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + + form = self.TestUpdateForm( + tenant=tenant, + instance=option, + data={ + "name": "Test Option", + "option_type": OptionType.CUSTOM, + "tenant": other_tenant.id, # Try to change tenant + "deleted": None, + "delete": False, + }, + ) + + assert form.is_valid() + updated_option = form.save() + assert updated_option.tenant == tenant # Tenant should not change + + +@pytest.mark.django_db +class TestForSelectionsForm: + """Test cases for SelectionsForm.""" + + class TestSelectionsForm(SelectionsForm): + """Test form for SelectionsForm.""" + + __test__ = False + + class Meta: + """Meta class for form.""" + + model = TaskPrioritySelection + + def test_form_initialization(self): + """Test selections form initialization.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestSelectionsForm(tenant=tenant) + + assert "selections" in form.fields + assert hasattr(form, "selection_model") + assert hasattr(form, "option_model") + + def test_selections_queryset(self): + """Test selections queryset is properly filtered.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create options of different types + mandatory = TaskPriorityOption.objects.create(name="Mandatory", option_type=OptionType.MANDATORY) + optional = TaskPriorityOption.objects.create(name="Optional", option_type=OptionType.OPTIONAL) + custom = TaskPriorityOption.objects.create(name="Custom", option_type=OptionType.CUSTOM, tenant=tenant) + + form = self.TestSelectionsForm(tenant=tenant) + queryset = form.fields["selections"].queryset + + assert mandatory in queryset + assert optional in queryset + assert custom in queryset + + def test_save_selections(self): + """Test saving selections.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create options + mandatory = TaskPriorityOption.objects.create(name="Mandatory", option_type=OptionType.MANDATORY) + optional = TaskPriorityOption.objects.create(name="Optional", option_type=OptionType.OPTIONAL) + + form = self.TestSelectionsForm(tenant=tenant, data={"selections": [optional.id]}) # Select optional option + + assert form.is_valid() + form.save() + + # Verify selections were saved + selected_options = TaskPriorityOption.objects.selected_options_for_tenant(tenant) + assert mandatory in selected_options # Mandatory should always be included + assert optional in selected_options # Optional should be included because we selected it + + def test_mandatory_options_always_selected(self): + """Test that mandatory options are always included in selections.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + mandatory = TaskPriorityOption.objects.create(name="Mandatory", option_type=OptionType.MANDATORY) + optional = TaskPriorityOption.objects.create(name="Optional", option_type=OptionType.OPTIONAL) + + # Try to submit form without selecting mandatory option + form = self.TestSelectionsForm(tenant=tenant, data={"selections": [optional.id]}) + + assert form.is_valid() + form.save() + + # Verify mandatory option is still selected + selections = TaskPrioritySelection.objects.filter(tenant=tenant, option=mandatory) + assert selections.exists() + + def test_remove_existing_selection(self): + """Test removing an existing selection.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + optional = TaskPriorityOption.objects.create(name="Optional", option_type=OptionType.OPTIONAL) + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=optional) + selection_id = selection.id + + # Submit form without the optional selection + form = self.TestSelectionsForm(tenant=tenant, data={"selections": []}) + + assert form.is_valid() + form.save() + + # Verify selection was removed + assert not TaskPrioritySelection.objects.filter(id=selection_id, deleted__isnull=True).exists() + + def test_concurrent_selection_updates(self): + """Test handling of concurrent selection updates.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + optional1 = TaskPriorityOption.objects.create(name="Optional 1", option_type=OptionType.OPTIONAL) + optional2 = TaskPriorityOption.objects.create(name="Optional 2", option_type=OptionType.OPTIONAL) + + # Create two forms simultaneously + form1 = self.TestSelectionsForm(tenant=tenant, data={"selections": [optional1.id]}) + form2 = self.TestSelectionsForm(tenant=tenant, data={"selections": [optional2.id]}) + + assert form1.is_valid() and form2.is_valid() + form1.save() + form2.save() + + # Verify final state includes both selections + selections = TaskPrioritySelection.objects.filter(tenant=tenant) + selected_options = [s.option.id for s in selections] + assert optional2.id in selected_options # Last save wins + + +@pytest.mark.django_db +class TestUserFacingFormMixin: + """Test cases for UserFacingFormMixin.""" + + class TestUserFacingForm(UserFacingFormMixin, forms.ModelForm): + """Test form class that uses UserFacingFormMixin.""" + + __test__ = False + + class Meta: + model = Task + fields = ["title", "description", "priority", "status"] + + @pytest.fixture(autouse=True) + def setup_test_environment(self): + """Set up test environment with required user.""" + self.user = User.objects.create_user(username=f"testuser{random.randrange(9999999)}", password="testpass") + yield + + def test_no_tenant_provided(self): + """Test form raises error when no tenant provided.""" + with pytest.raises(NoTenantProvidedFromViewError): + self.TestUserFacingForm() + + def test_handle_deleted_selection(self): + """Test handling of deleted selections.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="To Delete", option_type=OptionType.CUSTOM, tenant=tenant) + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=option) + + task = Task.objects.create(title="Test Task", description="Test Description", priority=option, user=self.user) + + # Soft delete the option + option.delete() + + form = self.TestUserFacingForm(tenant=tenant, instance=task) + assert option not in form.fields["priority"].queryset + + def test_tenant_field_hidden(self): + """Test tenant field is hidden in form.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + form = self.TestUserFacingForm(tenant=tenant) + + if "tenant" in form.fields: # Only test if the form has a tenant field + assert isinstance(form.fields["tenant"].widget, forms.HiddenInput) + assert form.fields["tenant"].initial == tenant + + def test_clean_tenant(self): + """Test clean method enforces correct tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + other_tenant = Tenant.objects.create(name="Other Tenant", subdomain="other-tenant") + + form = self.TestUserFacingForm( + tenant=tenant, + data={ + "title": "Test Task", + "description": "Test Description", + "tenant": other_tenant.id, # Try to submit different tenant + }, + ) + + form.is_valid() # Run validation + assert form.cleaned_data.get("tenant") == tenant # Should enforce original tenant + + def test_foreign_key_field_filtering(self): + """Test filtering of foreign key fields.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + other_tenant = Tenant.objects.create(name="Other Tenant", subdomain="other-tenant") + + # Create options for different tenants + tenant_option = TaskPriorityOption.objects.create( + name="Tenant Option", option_type=OptionType.CUSTOM, tenant=tenant + ) + other_option = TaskPriorityOption.objects.create( + name="Other Option", option_type=OptionType.CUSTOM, tenant=other_tenant + ) + + form = self.TestUserFacingForm(tenant=tenant) + priority_queryset = form.fields["priority"].queryset + + assert tenant_option in priority_queryset + assert other_option not in priority_queryset + + def test_multiple_foreign_key_fields(self): + """Test handling of multiple foreign key fields to option models.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + priority = TaskPriorityOption.objects.create(name="Priority", option_type=OptionType.CUSTOM, tenant=tenant) + status = TaskStatusOption.objects.create(name="Status", option_type=OptionType.CUSTOM, tenant=tenant) + + form = self.TestUserFacingForm(tenant=tenant) + + # Verify both foreign key fields are properly filtered + assert priority in form.fields["priority"].queryset + assert status in form.fields["status"].queryset + + def test_field_initial_values(self): + """Test initial values for foreign key fields with existing instance.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + priority = TaskPriorityOption.objects.create(name="Priority", option_type=OptionType.CUSTOM, tenant=tenant) + task = Task.objects.create(title="Test Task", description="Test Description", priority=priority, user=self.user) + + form = self.TestUserFacingForm(tenant=tenant, instance=task) + assert form.fields["priority"].initial == priority.id diff --git a/example_project/test_managers.py b/example_project/test_managers.py new file mode 100644 index 0000000..2dad7d4 --- /dev/null +++ b/example_project/test_managers.py @@ -0,0 +1,34 @@ +import pytest + +from example_project.example.models import TaskPriorityOption +from example_project.example.models import TaskPrioritySelection +from example_project.example.models import Tenant + + +@pytest.mark.django_db +class TestTenantOptionsManagers: + """Test cases for the managers.""" + + def test_create_mandatory_option(self): + """Test creating a mandatory option.""" + TaskPriorityOption.objects.create_mandatory(name="Critical") + option = TaskPriorityOption.objects.get(name="Critical") + assert option.option_type == "dm" + + def test_create_custom_option_for_tenant(self): + """Test creating a custom option for a tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + TaskPriorityOption.objects.create_for_tenant(tenant=tenant, name="Custom Priority") + option = TaskPriorityOption.objects.get(name="Custom Priority", tenant=tenant) + assert option.option_type == "cu" + assert option.tenant == tenant + + def test_options_for_tenant(self): + """Test retrieving options for a tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + TaskPriorityOption.objects.create_mandatory(name="Critical") + TaskPriorityOption.objects.create_optional(name="Low") + TaskPriorityOption.objects.create_for_tenant(tenant=tenant, name="Custom Priority") + + options = TaskPriorityOption.objects.options_for_tenant(tenant=tenant) + assert len(options) == 3 diff --git a/example_project/test_models.py b/example_project/test_models.py new file mode 100644 index 0000000..87feab4 --- /dev/null +++ b/example_project/test_models.py @@ -0,0 +1,407 @@ +"""Test cases for the models.""" + +import pytest +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db import transaction + +from django_tenant_options.choices import OptionType +from django_tenant_options.models import validate_model_has_attribute +from django_tenant_options.models import validate_model_is_concrete +from example_project.example.models import TaskPriorityOption +from example_project.example.models import TaskPrioritySelection +from example_project.example.models import Tenant + + +@pytest.mark.django_db +class TestTenantOptionsModels: + """Test cases for the models.""" + + def test_create_tenant(self): + """Test creating a tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + assert tenant.name == "Test Tenant" + assert tenant.subdomain == "test-tenant" + + def test_create_task_priority_option(self): + """Test creating a task priority option.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Critical", option_type="dm") + assert option.name == "Critical" + assert option.option_type == "dm" + + def test_create_task_priority_selection(self): + """Test creating a task priority selection.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Major", option_type="dm") + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=option) + assert selection.tenant == tenant + assert selection.option == option + + def test_uniqueness_of_two_custom_option_names(self): + """Test the uniqueness of option names.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + TaskPriorityOption.objects.create(name="Major", option_type="cu", tenant=tenant) + + with pytest.raises(IntegrityError): + TaskPriorityOption.objects.create(name="Major", option_type="cu", tenant=tenant) + + def test_uniqueness_of_custom_and_other_option_names(self): + """Test the uniqueness of option names.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + TaskPriorityOption.objects.create(name="Major", option_type="dm") + + with pytest.raises(ValidationError): + TaskPriorityOption.objects.create(name="Major", option_type="cu", tenant=tenant) + + def test_check_constraint_on_custom_options(self): + """Test the check constraint on custom options.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Test creating without tenant - should fail + try: + with transaction.atomic(): + TaskPriorityOption.objects.create(name="Custom Option", option_type="cu") + pytest.fail("Should have raised an IntegrityError") + except IntegrityError: + pass + + # Test creating with tenant - should succeed + option = TaskPriorityOption.objects.create(name="Custom Option", option_type="cu", tenant=tenant) + assert option.tenant == tenant + assert option.name == "Custom Option" + assert option.option_type == "cu" + + +@pytest.mark.django_db +class TestOptionModel: + """Test cases for the AbstractOption functionalities.""" + + def test_soft_delete_behavior(self): + """Test soft delete functionality of options.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + + # Test soft delete + option.delete() + assert option.deleted is not None + assert TaskPriorityOption.objects.filter(id=option.id).exists() + + # Test that soft-deleted options are excluded from active queryset + assert not TaskPriorityOption.objects.active().filter(id=option.id).exists() + assert TaskPriorityOption.objects.deleted().filter(id=option.id).exists() + + def test_queryset_methods(self): + """Test custom queryset methods.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create different types of options + mandatory = TaskPriorityOption.objects.create(name="Mandatory", option_type=OptionType.MANDATORY) + optional = TaskPriorityOption.objects.create(name="Optional", option_type=OptionType.OPTIONAL) + custom = TaskPriorityOption.objects.create(name="Custom", option_type=OptionType.CUSTOM, tenant=tenant) + + # Test custom_options() method + custom_options = TaskPriorityOption.objects.custom_options() + assert custom in custom_options + assert mandatory not in custom_options + assert optional not in custom_options + + def test_default_options_sync(self): + """Test syncing of default options.""" + # Make sure default options have been created + initial_options = TaskPriorityOption.objects._update_default_options() + + # initial_options = set(TaskPriorityOption.objects.values_list('name', flat=True)) + assert "High" in initial_options # From default_options in model + assert "Low" in initial_options # From default_options in model + + # Verify all default options exist + # for name in TaskPriorityOption.default_options.keys(): + for name in TaskPriorityOption.default_options: + assert TaskPriorityOption.objects.filter(name=name).exists() + + def test_multiple_tenant_options(self): + """Test options behavior with multiple tenants.""" + tenant1 = Tenant.objects.create(name="Tenant 1", subdomain="tenant1") + tenant2 = Tenant.objects.create(name="Tenant 2", subdomain="tenant2") + + # Create custom options for each tenant + option1 = TaskPriorityOption.objects.create(name="Custom 1", option_type=OptionType.CUSTOM, tenant=tenant1) + option2 = TaskPriorityOption.objects.create(name="Custom 2", option_type=OptionType.CUSTOM, tenant=tenant2) + + # Test options_for_tenant + tenant1_options = TaskPriorityOption.objects.options_for_tenant(tenant1) + assert option1 in tenant1_options + assert option2 not in tenant1_options + + # Test mandatory options appear for both tenants + mandatory = TaskPriorityOption.objects.create(name="Mandatory Test", option_type=OptionType.MANDATORY) + assert mandatory in TaskPriorityOption.objects.options_for_tenant(tenant1) + assert mandatory in TaskPriorityOption.objects.options_for_tenant(tenant2) + + def test_undelete_functionality(self): + """Test undelete functionality of options.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + + # Delete and verify + option.delete() + assert option.deleted is not None + + # Test undelete through queryset method + TaskPriorityOption.objects.filter(id=option.id).undelete() + option.refresh_from_db() + assert option.deleted is None + assert TaskPriorityOption.objects.active().filter(id=option.id).exists() + + def test_override_delete_functionality(self): + """Test override delete functionality.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + option_id = option.id + + # Test hard delete with override + option.delete(override=True) + + assert not TaskPriorityOption.objects.filter(id=option_id).exists() + + def test_str_representation(self): + """Test string representation of option models.""" + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.MANDATORY) + assert str(option) == "Test Option" + + def test_concurrent_option_creation(self): + """Test handling of concurrent option creation.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Try to create options with same name concurrently + with transaction.atomic(): + TaskPriorityOption.objects.create(name="Concurrent Test", option_type=OptionType.CUSTOM, tenant=tenant) + + with pytest.raises(IntegrityError): + TaskPriorityOption.objects.create(name="Concurrent Test", option_type=OptionType.CUSTOM, tenant=tenant) + + def test_case_insensitive_name_uniqueness(self): + """Test case-insensitive uniqueness of option names.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + + # Try to create option with same name but different case + with pytest.raises(IntegrityError): + TaskPriorityOption.objects.create(name="TEST OPTION", option_type=OptionType.CUSTOM, tenant=tenant) + + def test_update_default_options_with_changes(self): + """Test updating default options when definitions change.""" + # Create an option that's not in default_options + extra_option = TaskPriorityOption.objects.create(name="Extra Option", option_type=OptionType.MANDATORY) + + # Update default options - should soft delete extra option + TaskPriorityOption.objects._update_default_options() + + extra_option.refresh_from_db() + assert extra_option.deleted is not None + + # Verify all default options still exist + # for name in TaskPriorityOption.default_options.keys(): + for name in TaskPriorityOption.default_options: + assert TaskPriorityOption.objects.active().filter(name=name).exists() + + +@pytest.mark.django_db +class TestSelectionModel: + """Test cases for the AbstractSelection functionalities.""" + + def test_selection_constraints(self): + """Test selection model constraints.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + other_tenant = Tenant.objects.create(name="Other Tenant", subdomain="other-tenant") + + custom_option = TaskPriorityOption.objects.create(name="Custom", option_type=OptionType.CUSTOM, tenant=tenant) + + # Test that a tenant can't select another tenant's custom option + with pytest.raises(ValueError): + TaskPrioritySelection.objects.create(tenant=other_tenant, option=custom_option) + + def test_selection_soft_delete(self): + """Test soft delete behavior of selections.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=option) + + # Test soft delete + selection.delete() + selection.refresh_from_db() + + assert selection.deleted is not None + assert not TaskPrioritySelection.objects.active().filter(id=selection.id).exists() + + def test_selected_options_for_tenant(self): + """Test retrieving selected options for a tenant.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create and select different types of options + mandatory = TaskPriorityOption.objects.create(name="Mandatory Test", option_type=OptionType.MANDATORY) + optional = TaskPriorityOption.objects.create(name="Optional Test", option_type=OptionType.OPTIONAL) + custom = TaskPriorityOption.objects.create(name="Custom Test", option_type=OptionType.CUSTOM, tenant=tenant) + + # Create selections + TaskPrioritySelection.objects.create(tenant=tenant, option=optional) + TaskPrioritySelection.objects.create(tenant=tenant, option=custom) + + selected_options = TaskPriorityOption.objects.selected_options_for_tenant(tenant) + + # Mandatory options should always be included + assert mandatory in selected_options + # Optional and custom options should be included only if selected + assert optional in selected_options + assert custom in selected_options + + def test_selection_cascade_on_option_delete(self): + """Test selection behavior when related option is deleted.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=option) + selection_id = selection.id + + # When option is soft-deleted + option.delete() + selection.refresh_from_db() + assert selection.deleted is None # Selection should remain active + + # When option is hard-deleted + option.delete(override=True) + # Selections are cascade deleted as well when we hard-delete an option + assert not TaskPrioritySelection.objects.filter(id=selection_id).exists() + + def test_bulk_selection_operations(self): + """Test bulk operations on selections.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create multiple options and selections + options = [ + TaskPriorityOption.objects.create(name=f"Option {i}", option_type=OptionType.CUSTOM, tenant=tenant) + for i in range(3) + ] + + selections = [TaskPrioritySelection.objects.create(tenant=tenant, option=option) for option in options] + + # Test bulk soft delete + TaskPrioritySelection.objects.filter(id__in=[s.id for s in selections]).delete() + + # Verify all selections are soft deleted + for selection in selections: + selection.refresh_from_db() + assert selection.deleted is not None + + # Test bulk undelete + TaskPrioritySelection.objects.filter(id__in=[s.id for s in selections]).undelete() + + # Verify all selections are active again + for selection in selections: + selection.refresh_from_db() + assert selection.deleted is None + + def test_create_selection_for_deleted_option(self): + """Test creating selection for a soft-deleted option.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + option = TaskPriorityOption.objects.create(name="Test Option", option_type=OptionType.CUSTOM, tenant=tenant) + + # Soft delete the option + option.delete() + + # Try to create selection for deleted option + # Should succeed since option still exists in database + selection = TaskPrioritySelection.objects.create(tenant=tenant, option=option) + assert selection.id is not None + + +@pytest.mark.django_db +class TestValidationFunctions: + """Test cases for the validation helper functions.""" + + def test_validate_model_is_concrete(self): + """Test validation of concrete models.""" + from django.db.models import Model + + # Create a test abstract model + class AbstractTestModel(Model): + """Test abstract model.""" + + class Meta: + """Meta class.""" + + abstract = True + + # Test that validation fails for abstract model + with pytest.raises(Exception): + validate_model_is_concrete(AbstractTestModel) + + # Test that validation passes for concrete model + validate_model_is_concrete(TaskPriorityOption) + + def test_validate_model_has_attribute(self): + """Test validation of model attributes.""" + # Test with existing attribute + validate_model_has_attribute(TaskPriorityOption, "selection_model") + + # Test with non-existent attribute + with pytest.raises(AttributeError): + validate_model_has_attribute(TaskPriorityOption, "nonexistent_attr") + + # Test with wrong attribute type + with pytest.raises(AttributeError): + validate_model_has_attribute(TaskPriorityOption, "selection_model", attr_type=int) + + +@pytest.mark.django_db +class TestOptionManagerMethods: + """Test cases for option manager methods.""" + + def test_create_for_tenant_validation(self): + """Test validation in create_for_tenant method.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Try to create option with same name as existing mandatory option + TaskPriorityOption.objects.create_mandatory(name="Mandatory Test") + + with pytest.raises(ValidationError): + TaskPriorityOption.objects.create_for_tenant(tenant=tenant, name="Mandatory Test") + + def test_manager_create_methods(self): + """Test all creation methods in the manager.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Test create_mandatory + mandatory = TaskPriorityOption.objects.create_mandatory("Mandatory Test") + assert mandatory.option_type == OptionType.MANDATORY + assert mandatory.tenant is None + + # Test create_optional + optional = TaskPriorityOption.objects.create_optional("Optional Test") + assert optional.option_type == OptionType.OPTIONAL + assert optional.tenant is None + + # Test create_for_tenant + custom = TaskPriorityOption.objects.create_for_tenant(tenant, "Custom Test") + assert custom.option_type == OptionType.CUSTOM + assert custom.tenant == tenant + + def test_update_default_option_validation(self): + """Test validation in _update_or_create_default_option method.""" + # Try to create default option with invalid option_type + with pytest.raises(Exception): + TaskPriorityOption.objects._update_or_create_default_option("Test Option", {"option_type": "invalid"}) + + def test_options_for_tenant_with_deleted(self): + """Test options_for_tenant including deleted options.""" + tenant = Tenant.objects.create(name="Test Tenant", subdomain="test-tenant") + + # Create and soft-delete an option + option = TaskPriorityOption.objects.create(name="Deleted Option", option_type=OptionType.CUSTOM, tenant=tenant) + option.delete() + + # Test without deleted options + assert option not in TaskPriorityOption.objects.options_for_tenant(tenant) + + # Test including deleted options + assert option in TaskPriorityOption.objects.options_for_tenant(tenant, include_deleted=True)