From aa665c0e7347cada2a234921fae1b87990a5a84a Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 27 Jan 2025 09:24:32 +0000 Subject: [PATCH] fix: ensure project names are unique (#5039) --- api/projects/serializers.py | 9 ++++ api/tests/integration/conftest.py | 2 +- .../unit/projects/test_unit_projects_views.py | 50 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/projects/serializers.py b/api/projects/serializers.py index 339b1b8fa90a..85440cde3cce 100644 --- a/api/projects/serializers.py +++ b/api/projects/serializers.py @@ -74,6 +74,15 @@ class ProjectCreateSerializer(ReadOnlyIfNotValidPlanMixin, ProjectListSerializer invalid_plans_regex = r"^(free|startup.*|scale-up.*)$" field_names = ("stale_flags_limit_days", "enable_realtime_updates") + class Meta(ProjectListSerializer.Meta): + validators = [ + serializers.UniqueTogetherValidator( + queryset=ProjectListSerializer.Meta.model.objects.all(), + fields=("name", "organisation"), + message="A project with this name already exists.", + ) + ] + def get_subscription(self) -> typing.Optional[Subscription]: view = self.context["view"] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 0a0cf6495c04..18a5e6be4443 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -61,7 +61,7 @@ def dynamo_enabled_project( ): settings.EDGE_ENABLED = True project_data = { - "name": "Test Project", + "name": "Dynamo Enabled Project", "organisation": organisation, } url = reverse("api-v1:projects:project-list") diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index 71f078356dbc..2c7243fb9125 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -20,7 +20,7 @@ from environments.dynamodb.types import ProjectIdentityMigrationStatus from environments.identities.models import Identity from features.models import Feature, FeatureSegment -from organisations.models import Organisation, Subscription +from organisations.models import Organisation, OrganisationRole, Subscription from organisations.permissions.models import ( OrganisationPermissionModel, UserOrganisationPermission, @@ -875,3 +875,51 @@ def test_delete_project_delete_handles_cascade_delete( mocked_handle_cascade_delete.delay.assert_called_once_with( kwargs={"project_id": project.id} ) + + +def test_cannot_create_duplicate_project_name( + admin_client: APIClient, + project: Project, +) -> None: + # Given + data = { + "name": project.name, + "organisation": project.organisation_id, + } + url = reverse("api-v1:projects:project-list") + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "non_field_errors": ["A project with this name already exists."] + } + + +def test_can_create_project_with_duplicate_name_in_another_organisation( + admin_user: FFAdminUser, + admin_client: APIClient, + project: Project, + organisation_two: Organisation, +) -> None: + # Given + assert project.organisation != organisation_two + admin_user.add_organisation(organisation_two, OrganisationRole.ADMIN) + + data = { + "name": project.name, + "organisation": organisation_two.id, + } + url = reverse("api-v1:projects:project-list") + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED