From 4dc0d879eaa87b64a0cb9bee07669964f466e8c1 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 19 Mar 2026 10:23:14 +0530 Subject: [PATCH 1/2] fix: make Feature.type writable on create The type field was made read-only in #6888, which broke API clients that explicitly set type to MULTIVARIATE when creating features. The model-level choices constraint still validates the value. --- api/features/serializers.py | 1 - .../unit/features/test_unit_features_views.py | 20 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index c27ee5aa78e5..a37c6b6ce7fb 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -229,7 +229,6 @@ class Meta: "created_date", "uuid", "project", - "type", ) def to_internal_value(self, data): # type: ignore[no-untyped-def] diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index f4010f9a95f9..1cc23efff23d 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4490,13 +4490,24 @@ def test_list_features__edge_v2_project__makes_one_dynamo_query( assert mock_table.query.call_count == 1 -def test_create_feature__type_provided__ignores_type_and_defaults_to_standard( +@pytest.mark.parametrize( + "feature_type, expected_status", + [ + (STANDARD, status.HTTP_201_CREATED), + (MULTIVARIATE, status.HTTP_201_CREATED), + ("boolean", status.HTTP_400_BAD_REQUEST), + ("FLAG", status.HTTP_400_BAD_REQUEST), + ], +) +def test_create_feature__type_provided__validates_and_sets_type( admin_client_new: APIClient, project: Project, + feature_type: str, + expected_status: int, ) -> None: # Given url = reverse("api-v1:projects:project-features-list", args=[project.id]) - data = {"name": "test_feature_type_readonly", "type": "boolean"} + data = {"name": f"test_feature_{feature_type}", "type": feature_type} # When response = admin_client_new.post( @@ -4504,8 +4515,9 @@ def test_create_feature__type_provided__ignores_type_and_defaults_to_standard( ) # Then - assert response.status_code == status.HTTP_201_CREATED - assert response.json()["type"] == STANDARD + assert response.status_code == expected_status + if expected_status == status.HTTP_201_CREATED: + assert response.json()["type"] == feature_type def test_create_feature__multivariate_options_provided__sets_type_to_multivariate( From 0ebd98fe0940478e659ff117e388dd154198a547 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 19 Mar 2026 10:37:41 +0530 Subject: [PATCH 2/2] fix: replace deprecated "FLAG" type in tests with STANDARD Existing tests were using the deprecated "FLAG" feature type which is now correctly rejected by the model-level choices constraint. --- api/tests/unit/features/test_unit_features_views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 1cc23efff23d..6f3bd178d4ae 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -648,7 +648,7 @@ def test_create_feature_only_triggers_write_to_dynamodb_once_per_environment( project.save() url = reverse("api-v1:projects:project-features-list", args=[project.id]) - data = {"name": "Test feature flag", "type": "FLAG", "project": project.id} + data = {"name": "Test feature flag", "type": STANDARD, "project": project.id} mock_dynamo_environment_wrapper.is_enabled = True mock_dynamo_environment_wrapper.reset_mock() @@ -1748,7 +1748,7 @@ def test_create_feature_returns_201_if_name_matches_regex( feature_name = "valid_feature_name" url = reverse("api-v1:projects:project-features-list", args=[project.id]) - data = {"name": feature_name, "type": "FLAG", "project": project.id} + data = {"name": feature_name, "type": STANDARD, "project": project.id} # When response = admin_client_new.post(url, data=data) @@ -1766,7 +1766,7 @@ def test_create_feature_returns_400_if_name_does_not_matches_regex( feature_name = "not_a_valid_feature_name" url = reverse("api-v1:projects:project-features-list", args=[project.id]) - data = {"name": feature_name, "type": "FLAG", "project": project.id} + data = {"name": feature_name, "type": STANDARD, "project": project.id} # When response = admin_client_new.post(url, data=data) @@ -1784,7 +1784,7 @@ def test_audit_log_created_when_feature_created( ) -> None: # Given url = reverse("api-v1:projects:project-features-list", args=[project.id]) - data = {"name": "Test feature flag", "type": "FLAG", "project": project.id} + data = {"name": "Test feature flag", "type": STANDARD, "project": project.id} # When response = admin_client_new.post(url, data=data)