diff --git a/api/conftest.py b/api/conftest.py index c4db0c583ccb..7954a01bc0b4 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -53,6 +53,10 @@ from projects.tags.models import Tag from segments.models import Condition, Segment, SegmentRule from task_processor.task_run_method import TaskRunMethod +from tests.types import ( + WithEnvironmentPermissionsCallable, + WithProjectPermissionsCallable, +) from users.models import FFAdminUser, UserPermissionGroup trait_key = "key1" @@ -188,7 +192,7 @@ def environment(project): @pytest.fixture() def with_environment_permissions( environment: Environment, staff_user: FFAdminUser -) -> typing.Callable[[list[str], int | None], UserEnvironmentPermission]: +) -> WithEnvironmentPermissionsCallable: """ Add environment permissions to the staff_user fixture. Defaults to associating to the environment fixture. @@ -211,7 +215,7 @@ def _with_environment_permissions( @pytest.fixture() def with_project_permissions( project: Project, staff_user: FFAdminUser -) -> typing.Callable: +) -> WithProjectPermissionsCallable: """ Add project permissions to the staff_user fixture. Defaults to associating to the project fixture. diff --git a/api/features/feature_segments/views.py b/api/features/feature_segments/views.py index e47213577401..6359a79f605b 100644 --- a/api/features/feature_segments/views.py +++ b/api/features/feature_segments/views.py @@ -7,6 +7,7 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response +from environments.models import Environment from features.feature_segments.serializers import ( FeatureSegmentChangePrioritiesSerializer, FeatureSegmentCreateSerializer, @@ -14,6 +15,9 @@ FeatureSegmentQuerySerializer, ) from features.models import FeatureSegment +from features.versioning.versioning_service import ( + get_current_live_environment_feature_version, +) from projects.permissions import VIEW_PROJECT from .permissions import FeatureSegmentPermissions @@ -53,6 +57,17 @@ def get_queryset(self): data=self.request.query_params ) filter_serializer.is_valid(raise_exception=True) + + environment_id = filter_serializer.validated_data["environment"] + environment = Environment.objects.get(id=environment_id) + if environment.use_v2_feature_versioning: + queryset = queryset.filter( + environment_feature_version=get_current_live_environment_feature_version( + environment_id=environment_id, + feature_id=filter_serializer.validated_data["feature"], + ) + ) + return queryset.select_related("segment").filter(**filter_serializer.data) return queryset diff --git a/api/features/versioning/versioning_service.py b/api/features/versioning/versioning_service.py index 9a2af394ab5f..b559632950a6 100644 --- a/api/features/versioning/versioning_service.py +++ b/api/features/versioning/versioning_service.py @@ -4,6 +4,7 @@ from django.utils import timezone from features.models import FeatureState +from features.versioning.models import EnvironmentFeatureVersion if typing.TYPE_CHECKING: from environments.models import Environment @@ -50,6 +51,7 @@ def get_environment_flags_list( "feature", "feature_state_value", "environment_feature_version", + "feature_segment", *additional_select_related_args, ) .prefetch_related(*additional_prefetch_related_args) @@ -63,7 +65,7 @@ def get_environment_flags_list( for feature_state in feature_states: key = ( feature_state.feature_id, - feature_state.feature_segment_id, + getattr(feature_state.feature_segment, "segment_id", None), feature_state.identity_id, ) current_feature_state = feature_states_dict.get(key) @@ -73,6 +75,21 @@ def get_environment_flags_list( return list(feature_states_dict.values()) +def get_current_live_environment_feature_version( + environment_id: int, feature_id: int +) -> EnvironmentFeatureVersion | None: + return ( + EnvironmentFeatureVersion.objects.filter( + environment_id=environment_id, + feature_id=feature_id, + published_at__isnull=False, + live_from__lte=timezone.now(), + ) + .order_by("-live_from") + .first() + ) + + def _build_environment_flags_qs_filter( environment: "Environment", feature_name: str = None, additional_filters: Q = None ) -> Q: diff --git a/api/pyproject.toml b/api/pyproject.toml index d7efb428f080..b9b2d196ff84 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -29,7 +29,7 @@ use_parentheses = true multi_line_output = 3 include_trailing_comma = true line_length = 79 -known_first_party=['analytics','app','custom_auth','environments','integrations','organisations','projects','segments','users','webhooks','api','audit','e2etests','features','permissions','util'] +known_first_party=['analytics','app','custom_auth','environments','integrations','organisations','projects','segments','tests','users','webhooks','api','audit','e2etests','features','permissions','util'] known_third_party=['_pytest','apiclient','app_analytics','axes','chargebee','core','coreapi','corsheaders','dj_database_url','django','django_lifecycle','djoser','drf_writable_nested','drf_yasg','environs','google','influxdb_client','ordered_model','pyotp','pytest','pytz','requests','responses','rest_framework','rest_framework_nested','rest_framework_recursive','sentry_sdk','shortuuid','simple_history','six','telemetry','tests','trench','whitenoise'] skip = ['migrations','.venv','.direnv'] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 7affdcda1965..4d9de2064473 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -7,10 +7,10 @@ from pytest_django.fixtures import SettingsWrapper from rest_framework import status from rest_framework.test import APIClient -from tests.integration.helpers import create_mv_option_with_api from app.utils import create_hash from organisations.models import Organisation +from tests.integration.helpers import create_mv_option_with_api @pytest.fixture() diff --git a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py index ecb7a3d18f13..ab170f8d3157 100644 --- a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py +++ b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.test import APIClient + from tests.integration.helpers import create_mv_option_with_api diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index 2f85e3b4229e..6e5bdee3194d 100644 --- a/api/tests/integration/environments/identities/test_integration_identities.py +++ b/api/tests/integration/environments/identities/test_integration_identities.py @@ -4,13 +4,13 @@ import pytest from django.urls import reverse from rest_framework import status + +from features.feature_types import MULTIVARIATE from tests.integration.helpers import ( create_feature_with_api, create_mv_option_with_api, ) -from features.feature_types import MULTIVARIATE - variant_1_value = "variant-1-value" variant_2_value = "variant-2-value" control_value = "control" diff --git a/api/tests/integration/environments/test_clone_environment.py b/api/tests/integration/environments/test_clone_environment.py index dd83f99aa46a..b239a4947131 100644 --- a/api/tests/integration/environments/test_clone_environment.py +++ b/api/tests/integration/environments/test_clone_environment.py @@ -5,6 +5,7 @@ from pytest_lazyfixture import lazy_fixture from rest_framework import status from rest_framework.test import APIClient + from tests.integration.helpers import ( get_env_feature_states_list_with_api, get_feature_segement_list_with_api, diff --git a/api/tests/integration/features/versioning/test_integration_v2_versioning.py b/api/tests/integration/features/versioning/test_integration_v2_versioning.py index 5b8394f3f9e2..91c1f0f325de 100644 --- a/api/tests/integration/features/versioning/test_integration_v2_versioning.py +++ b/api/tests/integration/features/versioning/test_integration_v2_versioning.py @@ -5,6 +5,7 @@ import pytest from django.urls import reverse from rest_framework import status + from tests.test_helpers import generate_segment_data from .types import ( diff --git a/api/tests/types.py b/api/tests/types.py new file mode 100644 index 000000000000..24d09ee771e6 --- /dev/null +++ b/api/tests/types.py @@ -0,0 +1,11 @@ +from typing import Callable + +from environments.permissions.models import UserEnvironmentPermission +from projects.models import Project, UserProjectPermission + +WithProjectPermissionsCallable = Callable[ + [list[str], Project | None], UserProjectPermission +] +WithEnvironmentPermissionsCallable = Callable[ + [list[str], Project | None], UserEnvironmentPermission +] diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py index 3ecdac6cab20..6aaea120e978 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py @@ -1,5 +1,3 @@ -from typing import Callable - from django.urls import reverse from pytest_mock import MockerFixture from rest_framework import status @@ -16,6 +14,7 @@ ) from environments.permissions.permissions import NestedEnvironmentPermissions from features.models import Feature +from tests.types import WithEnvironmentPermissionsCallable def test_edge_identity_view_set_get_permissions(): @@ -97,7 +96,7 @@ def test_edge_identity_viewset_returns_404_for_invalid_environment_key(admin_cli def test_get_edge_identity_overrides_for_a_feature( staff_client: APIClient, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, mocker: MockerFixture, feature: Feature, environment: Environment, @@ -166,7 +165,7 @@ def test_get_edge_identity_overrides_for_a_feature( def test_user_without_manage_identities_permission_cannot_get_edge_identity_overrides_for_a_feature( staff_client: APIClient, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, feature: Feature, environment: Environment, ) -> None: diff --git a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py index 8534939cc3d8..3fae296896b7 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py @@ -3,12 +3,12 @@ import pytest from django.urls import reverse from rest_framework import status -from tests.unit.environments.helpers import get_environment_user_client from environments.permissions.constants import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from tests.unit.environments.helpers import get_environment_user_client def test_user_without_update_feature_state_permission_cannot_create_identity_feature_state( diff --git a/api/tests/unit/environments/test_unit_environments_feature_states_views.py b/api/tests/unit/environments/test_unit_environments_feature_states_views.py index 9b969eda6a27..ec335dcf8cb7 100644 --- a/api/tests/unit/environments/test_unit_environments_feature_states_views.py +++ b/api/tests/unit/environments/test_unit_environments_feature_states_views.py @@ -3,12 +3,12 @@ import pytest from django.urls import reverse from rest_framework import status -from tests.unit.environments.helpers import get_environment_user_client from environments.permissions.constants import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from tests.unit.environments.helpers import get_environment_user_client def test_user_without_update_feature_state_permission_cannot_update_feature_state( diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index 6cf0083d598c..494b4e70513f 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -28,6 +28,7 @@ ) from projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT from segments.models import Condition, SegmentRule +from tests.types import WithEnvironmentPermissionsCallable from users.models import FFAdminUser from util.tests import Helper @@ -605,7 +606,7 @@ def test_create_environment_without_required_metadata_returns_400( def test_view_environment_with_staff__query_count_is_expected( staff_client: APIClient, environment: Environment, - with_environment_permissions: Callable[[list[str], int], None], + with_environment_permissions: WithEnvironmentPermissionsCallable, project: Project, django_assert_num_queries: Callable[[int], None], environment_metadata_a: Metadata, diff --git a/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py b/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py index 1a510442582d..48b4210645ee 100644 --- a/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py +++ b/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py @@ -1,30 +1,39 @@ import json -from typing import Callable import pytest from django.urls import reverse from pytest_lazyfixture import lazy_fixture from rest_framework import status +from rest_framework.test import APIClient from environments.models import Environment from environments.permissions.constants import ( MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, ) -from features.models import Feature, FeatureSegment +from features.models import Feature, FeatureSegment, FeatureState +from features.versioning.models import EnvironmentFeatureVersion from projects.models import Project, UserProjectPermission from projects.permissions import VIEW_PROJECT from segments.models import Segment +from tests.types import ( + WithEnvironmentPermissionsCallable, + WithProjectPermissionsCallable, +) from users.models import FFAdminUser @pytest.mark.parametrize( "client, num_queries", [ - (lazy_fixture("admin_client"), 2), # 1 for paging, 1 for result ( - lazy_fixture("admin_master_api_key_client"), + lazy_fixture("admin_client"), 3, + ), # 1 for paging, 1 for result, 1 for getting the current live version + ( + lazy_fixture("admin_master_api_key_client"), + 4, ), # an extra one for master_api_key ], ) @@ -152,7 +161,7 @@ def test_create_feature_segment_staff_with_permission( environment: Environment, staff_client: FFAdminUser, staff_user: FFAdminUser, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, ) -> None: # Given data = { @@ -178,7 +187,7 @@ def test_create_feature_segment_staff_wrong_permission( environment: Environment, staff_client: FFAdminUser, staff_user: FFAdminUser, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, ): # Given data = { @@ -265,7 +274,7 @@ def test_update_priority_for_staff( feature: Feature, staff_client: FFAdminUser, staff_user: FFAdminUser, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, ) -> None: # Given url = reverse("api-v1:features:feature-segment-update-priorities") @@ -353,8 +362,8 @@ def test_get_feature_segment_by_uuid_for_staff( staff_user: FFAdminUser, environment: Environment, feature: Feature, - with_environment_permissions: Callable, - with_project_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given url = reverse( @@ -415,7 +424,7 @@ def test_get_feature_segment_by_id_for_staff( staff_user: FFAdminUser, environment: Environment, feature: Feature, - with_environment_permissions: Callable, + with_environment_permissions: WithEnvironmentPermissionsCallable, ): # Given url = reverse("api-v1:features:feature-segment-detail", args=[feature_segment.id]) @@ -519,3 +528,71 @@ def test_creating_segment_override_reaching_max_limit( == "The environment has reached the maximum allowed segments overrides limit." ) assert environment.feature_segments.count() == 1 + + +def test_get_feature_segments_only_returns_latest_version( + staff_user: FFAdminUser, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, + with_environment_permissions: WithEnvironmentPermissionsCallable, + project: Project, + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, +) -> None: + # Given + url = "%s?feature=%d&environment=%d" % ( + reverse("api-v1:features:feature-segment-list"), + feature.id, + environment_v2_versioning.id, + ) + with_project_permissions([VIEW_PROJECT]) + with_environment_permissions([VIEW_ENVIRONMENT]) + + # grab the current version (v0) + version_0 = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + assert version_0 + + # now let's create a new version with a segment override + version_1 = EnvironmentFeatureVersion.objects.create( + feature=feature, environment=environment_v2_versioning + ) + feature_segment_v1 = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=version_1, + ) + FeatureState.objects.create( + feature=feature, + environment=environment_v2_versioning, + feature_segment=feature_segment_v1, + environment_feature_version=version_1, + ) + version_1.publish(staff_user, persist=True) + + # and let's create another new version, which will trigger a duplication + # of the feature segment into the new version + version_2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version_2.publish(published_by=staff_user, persist=True) + + # Let's grab the latest versioned feature segment, so we can check for it's + # (exclusive) existence in the response from the API. + feature_segment_v2 = FeatureSegment.objects.get( + feature=feature, segment=segment, environment_feature_version=version_2 + ) + assert feature_segment_v2 != feature_segment_v1 + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["id"] == feature_segment_v2.id diff --git a/api/tests/unit/features/import_export/test_unit_features_import_export_views.py b/api/tests/unit/features/import_export/test_unit_features_import_export_views.py index 34b5f51552f5..4c63a9bda660 100644 --- a/api/tests/unit/features/import_export/test_unit_features_import_export_views.py +++ b/api/tests/unit/features/import_export/test_unit_features_import_export_views.py @@ -1,5 +1,4 @@ import json -from typing import Callable import pytest from django.core.files.uploadedfile import SimpleUploadedFile @@ -19,6 +18,7 @@ from projects.models import Project from projects.permissions import VIEW_PROJECT from projects.tags.models import Tag +from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser @@ -66,7 +66,7 @@ def test_list_feature_export_with_filtered_environments( staff_client: APIClient, project: Project, environment: Environment, - with_project_permissions: Callable[[list[str], int], None], + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given with_project_permissions([VIEW_PROJECT]) diff --git a/api/tests/unit/features/test_unit_features_permissions.py b/api/tests/unit/features/test_unit_features_permissions.py index 00db4bdcc520..95812efc4770 100644 --- a/api/tests/unit/features/test_unit_features_permissions.py +++ b/api/tests/unit/features/test_unit_features_permissions.py @@ -1,4 +1,3 @@ -from typing import Callable from unittest import mock from unittest.mock import MagicMock @@ -19,6 +18,7 @@ VIEW_PROJECT, NestedProjectPermissions, ) +from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser, UserPermissionGroup @@ -66,7 +66,7 @@ def test_project_admin_can_list_features( def test_project_user_with_read_access_can_list_features( staff_user: FFAdminUser, project: Project, - with_project_permissions: Callable[[list[str], int], None], + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given feature_permissions = FeaturePermissions() @@ -258,7 +258,7 @@ def test_project_admin_can_view_feature( def test_project_user_with_view_project_permission_can_view_feature( staff_user: FFAdminUser, project: Project, - with_project_permissions: Callable[[list[str], int], None], + with_project_permissions: WithProjectPermissionsCallable, feature: Feature, ) -> None: # Given @@ -344,7 +344,7 @@ def test_project_admin_can_edit_feature( def test_project_user_cannot_edit_feature( action: str, staff_user: FFAdminUser, - with_project_permissions: Callable[[list[str], int], None], + with_project_permissions: WithProjectPermissionsCallable, feature: Feature, ) -> None: # Given @@ -398,7 +398,7 @@ def test_project_admin_can_delete_feature( def test_project_user_with_delete_feature_permission_can_delete_feature( staff_user: FFAdminUser, - with_project_permissions: Callable[[list[str], int], None], + with_project_permissions: WithProjectPermissionsCallable, feature: Feature, ) -> None: # Given diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 06b4bcf40480..b9c951b4e7a7 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -1,7 +1,6 @@ import json import uuid from datetime import date, datetime, timedelta -from typing import Callable from unittest import TestCase, mock import pytest @@ -27,6 +26,7 @@ from environments.permissions.constants import ( MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, ) from environments.permissions.models import UserEnvironmentPermission from features.feature_types import MULTIVARIATE @@ -37,12 +37,17 @@ FeatureStateValue, ) from features.multivariate.models import MultivariateFeatureOption +from features.versioning.models import EnvironmentFeatureVersion from organisations.models import Organisation, OrganisationRole from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission from projects.permissions import CREATE_FEATURE, VIEW_PROJECT from projects.tags.models import Tag from segments.models import Segment +from tests.types import ( + WithEnvironmentPermissionsCallable, + WithProjectPermissionsCallable, +) from users.models import FFAdminUser, UserPermissionGroup from util.tests import Helper from webhooks.webhooks import WebhookEventType @@ -1689,7 +1694,7 @@ def test_list_features_group_owners( staff_client: APIClient, project: Project, feature: Feature, - with_project_permissions: Callable[[list[str], int | None], UserProjectPermission], + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given with_project_permissions([VIEW_PROJECT]) @@ -2221,9 +2226,7 @@ def test_cannot_update_feature_of_a_feature_state( def test_create_segment_override__using_simple_feature_state_viewset__allows_manage_segment_overrides( staff_client: APIClient, - with_environment_permissions: Callable[ - [list[str], int | None], UserEnvironmentPermission - ], + with_environment_permissions: WithEnvironmentPermissionsCallable, environment: Environment, feature: Feature, segment: Segment, @@ -2260,9 +2263,7 @@ def test_create_segment_override__using_simple_feature_state_viewset__allows_man def test_create_segment_override__using_simple_feature_state_viewset__denies_update_feature_state( staff_client: APIClient, - with_environment_permissions: Callable[ - [list[str], int | None], UserEnvironmentPermission - ], + with_environment_permissions: WithEnvironmentPermissionsCallable, environment: Environment, feature: Feature, segment: Segment, @@ -2295,9 +2296,7 @@ def test_create_segment_override__using_simple_feature_state_viewset__denies_upd def test_update_segment_override__using_simple_feature_state_viewset__allows_manage_segment_overrides( staff_client: APIClient, - with_environment_permissions: Callable[ - [list[str], int | None], UserEnvironmentPermission - ], + with_environment_permissions: WithEnvironmentPermissionsCallable, environment: Environment, feature: Feature, segment: Segment, @@ -2341,9 +2340,7 @@ def test_update_segment_override__using_simple_feature_state_viewset__allows_man def test_update_segment_override__using_simple_feature_state_viewset__denies_update_feature_state( staff_client: APIClient, - with_environment_permissions: Callable[ - [list[str], int | None], UserEnvironmentPermission - ], + with_environment_permissions: WithEnvironmentPermissionsCallable, environment: Environment, feature: Feature, segment: Segment, @@ -2381,7 +2378,7 @@ def test_list_features_n_plus_1( staff_client: APIClient, project: Project, feature: Feature, - with_project_permissions: Callable, + with_project_permissions: WithProjectPermissionsCallable, django_assert_num_queries: DjangoAssertNumQueries, environment: Environment, ) -> None: @@ -2410,7 +2407,7 @@ def test_list_features_with_union_tag( staff_client: APIClient, project: Project, feature: Feature, - with_project_permissions: Callable, + with_project_permissions: WithProjectPermissionsCallable, django_assert_num_queries: DjangoAssertNumQueries, environment: Environment, ) -> None: @@ -2462,7 +2459,7 @@ def test_list_features_with_intersection_tag( staff_client: APIClient, project: Project, feature: Feature, - with_project_permissions: Callable, + with_project_permissions: WithProjectPermissionsCallable, django_assert_num_queries: DjangoAssertNumQueries, environment: Environment, ) -> None: @@ -2506,3 +2503,54 @@ def test_list_features_with_intersection_tag( assert response.data["results"][0]["id"] == feature2.id assert response.data["results"][0]["tags"] == [tag1.id, tag2.id] + + +def test_simple_feature_state_returns_only_latest_versions( + staff_client: APIClient, + staff_user: FFAdminUser, + with_environment_permissions: WithEnvironmentPermissionsCallable, + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, +) -> None: + # Given + url = "%s?environment=%d" % ( + reverse("api-v1:features:featurestates-list"), + environment_v2_versioning.id, + ) + + with_environment_permissions( + [VIEW_ENVIRONMENT], environment_id=environment_v2_versioning.id + ) + + # Let's create some new versions, with some segment overrides + version_2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + feature_segment_v2 = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=version_2, + ) + FeatureState.objects.create( + feature_segment=feature_segment_v2, + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=version_2, + ) + version_2.publish(staff_user) + + version_3 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version_3.publish(staff_user) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 2 diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py similarity index 82% rename from api/tests/unit/features/versioning/test_unit_versioning_versioning.py rename to api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index 85eb4989adcb..d026e291d8de 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.db.models import Q from django.utils import timezone @@ -6,6 +8,7 @@ from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion from features.versioning.versioning_service import ( + get_current_live_environment_feature_version, get_environment_flags_list, get_environment_flags_queryset, ) @@ -145,3 +148,32 @@ def test_get_environment_flags_v2_versioning_returns_latest_live_versions_of_fea environment_feature_1_version_2_feature_state, environment_feature_2_version_1_feature_state, } + + +def test_get_current_live_environment_feature_version( + environment_v2_versioning: Environment, staff_user: FFAdminUser, feature: Feature +) -> None: + # Given + # The initial version + version_1 = EnvironmentFeatureVersion.objects.get( + environment=environment_v2_versioning, feature=feature + ) + + # and an unpublished version + EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + + # and a version that is published but not yet live + future_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + future_version.publish(staff_user, live_from=timezone.now() + timedelta(days=1)) + + # When + latest_version = get_current_live_environment_feature_version( + environment_id=environment_v2_versioning.id, feature_id=feature.id + ) + + # Then + assert latest_version == version_1 diff --git a/api/tests/unit/organisations/chargebee/conftest.py b/api/tests/unit/organisations/chargebee/conftest.py index 47b5ecf8fa59..0fe8b155526b 100644 --- a/api/tests/unit/organisations/chargebee/conftest.py +++ b/api/tests/unit/organisations/chargebee/conftest.py @@ -2,13 +2,13 @@ import pytest from pytest_mock import MockerFixture + +from organisations.chargebee.metadata import ChargebeeObjMetadata from tests.unit.organisations.chargebee.test_unit_chargebee_chargebee import ( MockChargeBeeAddOn, MockChargeBeeSubscriptionResponse, ) -from organisations.chargebee.metadata import ChargebeeObjMetadata - ChargebeeCacheMocker = typing.Callable[ [ typing.Optional[dict[str, ChargebeeObjMetadata]], diff --git a/api/tests/unit/telemetry/test_unit_telemetry_serializers.py b/api/tests/unit/telemetry/test_unit_telemetry_serializers.py index 5c42dd0e339c..a7284b3f9f2b 100644 --- a/api/tests/unit/telemetry/test_unit_telemetry_serializers.py +++ b/api/tests/unit/telemetry/test_unit_telemetry_serializers.py @@ -2,6 +2,7 @@ from django.test import override_settings from telemetry.serializers import TelemetrySerializer + from tests.unit.telemetry.helpers import get_example_telemetry_data diff --git a/api/tests/unit/telemetry/test_unit_telemetry_telemetry.py b/api/tests/unit/telemetry/test_unit_telemetry_telemetry.py index cecc6ac9a0a9..02b91f686481 100644 --- a/api/tests/unit/telemetry/test_unit_telemetry_telemetry.py +++ b/api/tests/unit/telemetry/test_unit_telemetry_telemetry.py @@ -3,6 +3,7 @@ import responses from telemetry.telemetry import SelfHostedTelemetryWrapper + from tests.unit.telemetry.helpers import get_example_telemetry_data