diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index fe8bb665135e..ff316a5da1d7 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -8,6 +8,7 @@ from environments.identities.traits.views import SDKTraits from environments.identities.views import SDKIdentities from environments.sdk.views import SDKEnvironmentAPIView +from features.feature_health.views import feature_health_webhook from features.views import SDKFeatureStates from integrations.github.views import github_webhook from organisations.views import chargebee_webhook @@ -49,6 +50,12 @@ # GitHub integration webhook re_path(r"github-webhook/", github_webhook, name="github-webhook"), re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"), + # Feature health webhook + re_path( + r"feature-health/(?P.{0,100})$", + feature_health_webhook, + name="feature-health-webhook", + ), # Client SDK urls re_path(r"^flags/$", SDKFeatureStates.as_view(), name="flags"), re_path(r"^identities/$", SDKIdentities.as_view(), name="sdk-identities"), diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 0d08d783d9a8..e3c40ab33b73 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -104,6 +104,7 @@ "environments.identities", "environments.identities.traits", "features", + "features.feature_health", "features.import_export", "features.multivariate", "features.versioning", diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 6fab2460890b..5240e2f51c35 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -10,3 +10,4 @@ class RelatedObjectType(enum.Enum): EDGE_IDENTITY = "Edge Identity" IMPORT_REQUEST = "Import request" EF_VERSION = "Environment feature version" + FEATURE_HEALTH = "Feature health status" diff --git a/api/conftest.py b/api/conftest.py index bde60d982095..78e9a03a4164 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -77,6 +77,7 @@ from segments.models import Condition, Segment, SegmentRule from tests.test_helpers import fix_issue_3869 from tests.types import ( + AdminClientAuthType, WithEnvironmentPermissionsCallable, WithOrganisationPermissionsCallable, WithProjectPermissionsCallable, @@ -1177,23 +1178,22 @@ def github_repository( ) -@pytest.fixture( - params=[ - "admin_client_original", - "admin_master_api_key_client", - ] -) -def admin_client_new( +@pytest.fixture(params=AdminClientAuthType.__args__) +def admin_client_auth_type( request: pytest.FixtureRequest, +) -> AdminClientAuthType: + return request.param + + +@pytest.fixture +def admin_client_new( + admin_client_auth_type: AdminClientAuthType, admin_client_original: APIClient, admin_master_api_key_client: APIClient, ) -> APIClient: - if request.param == "admin_client_original": - yield admin_client_original - elif request.param == "admin_master_api_key_client": - yield admin_master_api_key_client - else: - assert False, "Request param mismatch" + if admin_client_auth_type == "master_api_key": + return admin_master_api_key_client + return admin_client_original @pytest.fixture() diff --git a/api/features/feature_health/__init__.py b/api/features/feature_health/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/feature_health/admin.py b/api/features/feature_health/admin.py new file mode 100644 index 000000000000..7931b7deebe0 --- /dev/null +++ b/api/features/feature_health/admin.py @@ -0,0 +1,35 @@ +import typing + +from django.contrib import admin +from django.http import HttpRequest + +from features.feature_health.models import FeatureHealthProvider +from features.feature_health.providers.services import ( + get_webhook_path_from_provider, +) + + +@admin.register(FeatureHealthProvider) +class FeatureHealthProviderAdmin(admin.ModelAdmin): + list_display = ( + "project", + "name", + "created_by", + "webhook_url", + ) + + def changelist_view( + self, + request: HttpRequest, + *args: typing.Any, + **kwargs: typing.Any, + ) -> None: + self.request = request + return super().changelist_view(request, *args, **kwargs) + + def webhook_url( + self, + instance: FeatureHealthProvider, + ) -> str: + path = get_webhook_path_from_provider(instance) + return self.request.build_absolute_uri(path) diff --git a/api/features/feature_health/apps.py b/api/features/feature_health/apps.py new file mode 100644 index 000000000000..6372813387ae --- /dev/null +++ b/api/features/feature_health/apps.py @@ -0,0 +1,6 @@ +from core.apps import BaseAppConfig + + +class FeatureHealthConfig(BaseAppConfig): + name = "features.feature_health" + default = True diff --git a/api/features/feature_health/constants.py b/api/features/feature_health/constants.py new file mode 100644 index 000000000000..3d42b593bd19 --- /dev/null +++ b/api/features/feature_health/constants.py @@ -0,0 +1,12 @@ +FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE = "Health provider %s set up for project %s." +FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE = "Health provider %s removed from project %s." + +FEATURE_HEALTH_EVENT_CREATED_MESSAGE = "Health status changed to %s for feature %s." +FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE = ( + "Health status changed to %s for feature %s in environment %s." +) +FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE = "\n\nProvided by %s" +FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE = "\n\nReason:\n%s" + +UNHEALTHY_TAG_LABEL = "Unhealthy" +UNHEALTHY_TAG_COLOUR = "#FFC0CB" diff --git a/api/features/feature_health/migrations/0001_initial.py b/api/features/feature_health/migrations/0001_initial.py new file mode 100644 index 000000000000..57c94fabc682 --- /dev/null +++ b/api/features/feature_health/migrations/0001_initial.py @@ -0,0 +1,273 @@ +# Generated by Django 4.2.18 on 2025-01-27 14:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_lifecycle.mixins +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("features", "0065_make_feature_value_size_configurable"), + ("api_keys", "0003_masterapikey_is_admin"), + ("environments", "0037_add_uuid_field"), + ("projects", "0026_add_change_request_approval_limit_to_projects"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalFeatureHealthProvider", + fields=[ + ( + "id", + models.IntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + choices=[("Sample", "Sample"), ("Grafana", "Grafana")], + max_length=50, + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "master_api_key", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="api_keys.masterapikey", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "historical feature health provider", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalFeatureHealthEvent", + fields=[ + ( + "id", + models.IntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ( + "type", + models.CharField( + choices=[("UNHEALTHY", "Unhealthy"), ("HEALTHY", "Healthy")], + max_length=50, + ), + ), + ( + "provider_name", + models.CharField(blank=True, max_length=255, null=True), + ), + ("reason", models.TextField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "environment", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="environments.environment", + ), + ), + ( + "feature", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="features.feature", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "master_api_key", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="api_keys.masterapikey", + ), + ), + ], + options={ + "verbose_name": "historical feature health event", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="FeatureHealthEvent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "type", + models.CharField( + choices=[("UNHEALTHY", "Unhealthy"), ("HEALTHY", "Healthy")], + max_length=50, + ), + ), + ( + "provider_name", + models.CharField(blank=True, max_length=255, null=True), + ), + ("reason", models.TextField(blank=True, null=True)), + ( + "environment", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="feature_health_events", + to="environments.environment", + ), + ), + ( + "feature", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feature_health_events", + to="features.feature", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.CreateModel( + name="FeatureHealthProvider", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "name", + models.CharField( + choices=[("Sample", "Sample"), ("Grafana", "Grafana")], + max_length=50, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="projects.project", + ), + ), + ], + options={ + "unique_together": {("name", "project")}, + }, + ), + ] diff --git a/api/features/feature_health/migrations/__init__.py b/api/features/feature_health/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/feature_health/models.py b/api/features/feature_health/models.py new file mode 100644 index 000000000000..666eb844f18b --- /dev/null +++ b/api/features/feature_health/models.py @@ -0,0 +1,167 @@ +import typing + +from core.models import ( + AbstractBaseExportableModel, + abstract_base_auditable_model_factory, +) +from django.db import models +from django_lifecycle import AFTER_CREATE, LifecycleModelMixin, hook + +from audit.related_object_type import RelatedObjectType +from features.feature_health.constants import ( + FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE, + FEATURE_HEALTH_EVENT_CREATED_MESSAGE, + FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE, + FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE, + FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE, + FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE, +) + +if typing.TYPE_CHECKING: + from environments.models import Environment + from features.models import Feature + from projects.models import Project + from users.models import FFAdminUser + + +class FeatureHealthProviderName(models.Choices): + SAMPLE = "Sample" + GRAFANA = "Grafana" + + +class FeatureHealthEventType(models.Choices): + UNHEALTHY = "UNHEALTHY" + HEALTHY = "HEALTHY" + + +class FeatureHealthProvider( + AbstractBaseExportableModel, + abstract_base_auditable_model_factory(["uuid"]), +): + history_record_class_path = ( + "features.feature_health.models.HistoricalFeatureHealthProvider" + ) + related_object_type = RelatedObjectType.FEATURE_HEALTH + + name = models.CharField(max_length=50, choices=FeatureHealthProviderName.choices) + project = models.ForeignKey("projects.Project", on_delete=models.CASCADE) + created_by = models.ForeignKey( + "users.FFAdminUser", + on_delete=models.SET_NULL, + null=True, + ) + + class Meta: + unique_together = ("name", "project") + + def get_create_log_message( + self, + history_instance: "FeatureHealthProvider", + ) -> str | None: + return FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE % (self.name, self.project.name) + + def get_delete_log_message( + self, + history_instance: "FeatureHealthProvider", + ) -> str | None: + return FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE % (self.name, self.project.name) + + def get_audit_log_author( + self, + history_instance: "FeatureHealthProvider", + ) -> "FFAdminUser | None": + return self.created_by + + def _get_project(self) -> "Project": + return self.project + + +class FeatureHealthEventManager(models.Manager): + def get_latest_by_feature( + self, + feature: "Feature", + ) -> "models.QuerySet[FeatureHealthEvent]": + return ( + self.filter(feature=feature) + .order_by("provider_name", "environment_id", "-created_at") + .distinct("provider_name", "environment_id") + ) + + def get_latest_by_project( + self, + project: "Project", + ) -> "models.QuerySet[FeatureHealthEvent]": + return ( + self.filter(feature__project=project) + .order_by("provider_name", "environment_id", "feature_id", "-created_at") + .distinct("provider_name", "environment_id", "feature_id") + ) + + +class FeatureHealthEvent( + LifecycleModelMixin, + AbstractBaseExportableModel, + abstract_base_auditable_model_factory(["uuid"]), +): + """ + Holds the events that are generated when a feature health is changed. + """ + + history_record_class_path = ( + "features.feature_health.models.HistoricalFeatureHealthEvent" + ) + related_object_type = RelatedObjectType.FEATURE_HEALTH + + objects: FeatureHealthEventManager = FeatureHealthEventManager() + + feature = models.ForeignKey( + "features.Feature", + on_delete=models.CASCADE, + related_name="feature_health_events", + ) + environment = models.ForeignKey( + "environments.Environment", + on_delete=models.CASCADE, + related_name="feature_health_events", + null=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + type = models.CharField(max_length=50, choices=FeatureHealthEventType.choices) + provider_name = models.CharField(max_length=255, null=True, blank=True) + reason = models.TextField(null=True, blank=True) + + @hook(AFTER_CREATE) + def set_feature_health_tag(self): + from features.feature_health.tasks import update_feature_unhealthy_tag + + update_feature_unhealthy_tag.delay(args=(self.feature.id,)) + + def get_create_log_message( + self, + history_instance: "FeatureHealthEvent", + ) -> str | None: + if self.environment: + message = FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE % ( + self.type, + self.feature.name, + self.environment.name, + ) + else: + message = FEATURE_HEALTH_EVENT_CREATED_MESSAGE % ( + self.type, + self.feature.name, + ) + if self.provider_name: + message += ( + FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE % self.provider_name + ) + if self.reason: + message += FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE % self.reason + return message + + def _get_project(self) -> "Project": + return self.feature.project + + def _get_environment(self) -> "Environment | None": + return self.environment diff --git a/api/features/feature_health/providers/__init__.py b/api/features/feature_health/providers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/feature_health/providers/sample/__init__.py b/api/features/feature_health/providers/sample/__init__.py new file mode 100644 index 000000000000..9a46e16aee62 --- /dev/null +++ b/api/features/feature_health/providers/sample/__init__.py @@ -0,0 +1,5 @@ +from features.feature_health.providers.sample.mappers import ( + map_payload_to_provider_response, +) + +__all__ = ("map_payload_to_provider_response",) diff --git a/api/features/feature_health/providers/sample/mappers.py b/api/features/feature_health/providers/sample/mappers.py new file mode 100644 index 000000000000..1317277f9020 --- /dev/null +++ b/api/features/feature_health/providers/sample/mappers.py @@ -0,0 +1,33 @@ +import json + +from features.feature_health.models import FeatureHealthEventType +from features.feature_health.providers.sample.types import ( + SampleEvent, + SampleEventStatus, +) +from features.feature_health.types import FeatureHealthProviderResponse + + +def map_sample_event_status_to_feature_health_event_type( + status: SampleEventStatus, +) -> FeatureHealthEventType: + return ( + FeatureHealthEventType.UNHEALTHY + if status == "unhealthy" + else FeatureHealthEventType.HEALTHY + ) + + +def map_payload_to_provider_response( + payload: str, +) -> FeatureHealthProviderResponse: + event_data: SampleEvent = json.loads(payload) + + return FeatureHealthProviderResponse( + feature_name=event_data["feature"], + environment_name=event_data.get("environment"), + event_type=map_sample_event_status_to_feature_health_event_type( + event_data["status"] + ), + reason=event_data.get("reason", ""), + ) diff --git a/api/features/feature_health/providers/sample/types.py b/api/features/feature_health/providers/sample/types.py new file mode 100644 index 000000000000..615aa863a4ff --- /dev/null +++ b/api/features/feature_health/providers/sample/types.py @@ -0,0 +1,10 @@ +import typing + +SampleEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"] + + +class SampleEvent(typing.TypedDict): + environment: typing.NotRequired[str] + feature: str + status: SampleEventStatus + reason: typing.NotRequired[str] diff --git a/api/features/feature_health/providers/services.py b/api/features/feature_health/providers/services.py new file mode 100644 index 000000000000..7b4ef61f6fe2 --- /dev/null +++ b/api/features/feature_health/providers/services.py @@ -0,0 +1,35 @@ +import uuid + +import structlog +from django.core import signing +from django.urls import reverse + +from features.feature_health.models import FeatureHealthProvider + +logger = structlog.get_logger("feature_health") + +_provider_webhook_signer = signing.Signer(sep="/", salt="feature_health") + + +def get_webhook_path_from_provider( + provider: FeatureHealthProvider, +) -> str: + webhook_path = _provider_webhook_signer.sign_object( + provider.uuid.hex, + ) + return reverse( + "api-v1:feature-health-webhook", + args=[webhook_path], + ) + + +def get_provider_from_webhook_path(path: str) -> FeatureHealthProvider | None: + try: + hex_string = _provider_webhook_signer.unsign_object(path) + except signing.BadSignature: + logger.warning("invalid-feature-health-webhook-path-requested", path=path) + return None + feature_health_provider_uuid = uuid.UUID(hex_string) + return FeatureHealthProvider.objects.filter( + uuid=feature_health_provider_uuid + ).first() diff --git a/api/features/feature_health/serializers.py b/api/features/feature_health/serializers.py new file mode 100644 index 000000000000..922b69d16657 --- /dev/null +++ b/api/features/feature_health/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from features.feature_health.models import ( + FeatureHealthEvent, + FeatureHealthProvider, + FeatureHealthProviderName, +) +from features.feature_health.providers.services import ( + get_webhook_path_from_provider, +) + + +class FeatureHealthEventSerializer(serializers.ModelSerializer): + class Meta: + model = FeatureHealthEvent + fields = read_only_fields = ( + "created_at", + "environment", + "feature", + "provider_name", + "reason", + "type", + ) + + +class FeatureHealthProviderSerializer(serializers.ModelSerializer): + created_by = serializers.SlugRelatedField(slug_field="email", read_only=True) + webhook_url = serializers.SerializerMethodField() + + def get_webhook_url(self, instance: FeatureHealthProvider) -> str: + request = self.context["request"] + path = get_webhook_path_from_provider(instance) + return request.build_absolute_uri(path) + + class Meta: + model = FeatureHealthProvider + fields = ( + "created_by", + "name", + "project", + "webhook_url", + ) + + +class CreateFeatureHealthProviderSerializer(serializers.Serializer): + name = serializers.ChoiceField(choices=FeatureHealthProviderName.choices) diff --git a/api/features/feature_health/services.py b/api/features/feature_health/services.py new file mode 100644 index 000000000000..5d332012e815 --- /dev/null +++ b/api/features/feature_health/services.py @@ -0,0 +1,91 @@ +import typing + +import structlog + +from environments.models import Environment +from features.feature_health.constants import ( + UNHEALTHY_TAG_COLOUR, + UNHEALTHY_TAG_LABEL, +) +from features.feature_health.models import ( + FeatureHealthEvent, + FeatureHealthEventType, + FeatureHealthProvider, + FeatureHealthProviderName, +) +from features.feature_health.providers import sample +from features.models import Feature +from projects.tags.models import Tag, TagType + +if typing.TYPE_CHECKING: + from features.feature_health.types import FeatureHealthProviderResponse + +logger = structlog.get_logger("feature_health") + + +def get_provider_response( + provider: FeatureHealthProvider, payload: str +) -> "FeatureHealthProviderResponse | None": + if provider.name == FeatureHealthProviderName.SAMPLE.value: + return sample.map_payload_to_provider_response(payload) + logger.error( + "invalid-feature-health-provider-requested", + provider_name=provider.name, + provider_id=provider.uuid, + ) + return None + + +def create_feature_health_event_from_provider( + provider: FeatureHealthProvider, + payload: str, +) -> FeatureHealthEvent | None: + try: + response = get_provider_response(provider, payload) + except (KeyError, ValueError) as exc: + logger.error( + "invalid-feature-health-event-data", + provider_name=provider.name, + project_id=provider.project.id, + exc_info=exc, + ) + return None + project = provider.project + if feature := Feature.objects.filter( + project=provider.project, name=response.feature_name + ).first(): + if response.environment_name: + environment = Environment.objects.filter( + project=project, name=response.environment_name + ).first() + else: + environment = None + return FeatureHealthEvent.objects.create( + feature=feature, + environment=environment, + provider_name=provider.name, + type=response.event_type, + reason=response.reason, + ) + return None + + +def update_feature_unhealthy_tag(feature: "Feature") -> None: + if feature_health_events := [ + *FeatureHealthEvent.objects.get_latest_by_feature(feature) + ]: + unhealthy_tag, _ = Tag.objects.get_or_create( + label=UNHEALTHY_TAG_LABEL, + project=feature.project, + defaults={"color": UNHEALTHY_TAG_COLOUR}, + is_system_tag=True, + type=TagType.UNHEALTHY, + ) + if any( + feature_health_event.type == FeatureHealthEventType.UNHEALTHY.value + for feature_health_event in feature_health_events + ): + feature.tags.add(unhealthy_tag) + else: + feature.tags.remove(unhealthy_tag) + feature.save() diff --git a/api/features/feature_health/tasks.py b/api/features/feature_health/tasks.py new file mode 100644 index 000000000000..f3d7e83e49dd --- /dev/null +++ b/api/features/feature_health/tasks.py @@ -0,0 +1,10 @@ +from task_processor.decorators import register_task_handler + +from features.feature_health import services +from features.models import Feature + + +@register_task_handler() +def update_feature_unhealthy_tag(feature_id: int) -> None: + if feature := Feature.objects.filter(id=feature_id).first(): + services.update_feature_unhealthy_tag(feature) diff --git a/api/features/feature_health/types.py b/api/features/feature_health/types.py new file mode 100644 index 000000000000..71909c573540 --- /dev/null +++ b/api/features/feature_health/types.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from features.feature_health.models import FeatureHealthEventType + + +@dataclass +class FeatureHealthProviderResponse: + feature_name: str + environment_name: str | None + event_type: FeatureHealthEventType + reason: str diff --git a/api/features/feature_health/views.py b/api/features/feature_health/views.py new file mode 100644 index 000000000000..8729d81f74b8 --- /dev/null +++ b/api/features/feature_health/views.py @@ -0,0 +1,112 @@ +import typing + +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, BasePermission +from rest_framework.request import Request +from rest_framework.response import Response + +from features.feature_health.models import ( + FeatureHealthEvent, + FeatureHealthProvider, +) +from features.feature_health.providers.services import ( + get_provider_from_webhook_path, +) +from features.feature_health.serializers import ( + CreateFeatureHealthProviderSerializer, + FeatureHealthEventSerializer, + FeatureHealthProviderSerializer, +) +from features.feature_health.services import ( + create_feature_health_event_from_provider, +) +from projects.models import Project +from projects.permissions import NestedProjectPermissions +from users.models import FFAdminUser + + +class FeatureHealthEventViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + serializer_class = FeatureHealthEventSerializer + pagination_class = None # set here to ensure documentation is correct + model_class = FeatureHealthEvent + + def get_permissions(self) -> list[BasePermission]: + return [NestedProjectPermissions()] + + def get_queryset(self) -> QuerySet[FeatureHealthProvider]: + if getattr(self, "swagger_fake_view", False): + return self.model_class.objects.none() + + project = get_object_or_404(Project, pk=self.kwargs["project_pk"]) + return self.model_class.objects.get_latest_by_project(project) + + +class FeatureHealthProviderViewSet( + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + serializer_class = FeatureHealthProviderSerializer + pagination_class = None # set here to ensure documentation is correct + model_class = FeatureHealthProvider + + def get_permissions(self) -> list[BasePermission]: + return [NestedProjectPermissions()] + + def get_queryset(self) -> QuerySet[FeatureHealthProvider]: + if getattr(self, "swagger_fake_view", False): + return self.model_class.objects.none() + + project = get_object_or_404(Project, pk=self.kwargs["project_pk"]) + return self.model_class.objects.filter(project=project) + + @swagger_auto_schema( + request_body=CreateFeatureHealthProviderSerializer, + responses={status.HTTP_201_CREATED: FeatureHealthProviderSerializer()}, + ) + def create(self, request: Request, *args, **kwargs) -> Response: + request_serializer = CreateFeatureHealthProviderSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + project = get_object_or_404(Project, pk=self.kwargs["project_pk"]) + + created_by = None + if isinstance(self.request.user, FFAdminUser): + created_by = self.request.user + + instance = self.model_class.objects.create( + project=project, + name=request_serializer.validated_data["name"], + created_by=created_by, + ) + + serializer = FeatureHealthProviderSerializer( + instance, + context={"request": request}, + ) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def feature_health_webhook(request: Request, **kwargs: typing.Any) -> Response: + path = kwargs["path"] + if not (provider := get_provider_from_webhook_path(path)): + return Response(status=status.HTTP_404_NOT_FOUND) + payload = request.body.decode("utf-8") + if create_feature_health_event_from_provider(provider=provider, payload=payload): + return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_400_BAD_REQUEST) diff --git a/api/projects/tags/migrations/0008_alter_tag_type.py b/api/projects/tags/migrations/0008_alter_tag_type.py new file mode 100644 index 000000000000..c6dad16059ba --- /dev/null +++ b/api/projects/tags/migrations/0008_alter_tag_type.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.17 on 2025-01-21 07:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0007_alter_tag_color"), + ] + + operations = [ + migrations.AlterField( + model_name="tag", + name="type", + field=models.CharField( + choices=[ + ("NONE", "None"), + ("STALE", "Stale"), + ("GITHUB", "Github"), + ("UNHEALTHY", "Unhealthy"), + ], + default="NONE", + help_text="Field used to provide a consistent identifier for the FE and API to use for business logic.", + max_length=100, + ), + ), + ] diff --git a/api/projects/tags/models.py b/api/projects/tags/models.py index 076245248043..b9d17eaf17ee 100644 --- a/api/projects/tags/models.py +++ b/api/projects/tags/models.py @@ -8,6 +8,7 @@ class TagType(models.Choices): NONE = "NONE" STALE = "STALE" GITHUB = "GITHUB" + UNHEALTHY = "UNHEALTHY" class Tag(AbstractBaseExportableModel): diff --git a/api/projects/urls.py b/api/projects/urls.py index b0150651f82a..f8da194fed89 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -8,6 +8,10 @@ from features.feature_external_resources.views import ( FeatureExternalResourceViewSet, ) +from features.feature_health.views import ( + FeatureHealthEventViewSet, + FeatureHealthProviderViewSet, +) from features.import_export.views import ( FeatureExportListView, FeatureImportListView, @@ -70,6 +74,16 @@ ProjectAuditLogViewSet, basename="project-audit", ) +projects_router.register( + "feature-health/providers", + FeatureHealthProviderViewSet, + basename="feature-health-providers", +) +projects_router.register( + "feature-health/events", + FeatureHealthEventViewSet, + basename="feature-health-events", +) if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") diff --git a/api/tests/integration/features/feature_health/__init__.py b/api/tests/integration/features/feature_health/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/integration/features/feature_health/conftest.py b/api/tests/integration/features/feature_health/conftest.py new file mode 100644 index 000000000000..c039bcc181f2 --- /dev/null +++ b/api/tests/integration/features/feature_health/conftest.py @@ -0,0 +1,30 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.fixture +def sample_feature_health_provider_webhook_url( + project: int, admin_client_new: APIClient +) -> str: + feature_health_provider_data = {"name": "Sample"} + url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) + response = admin_client_new.post(url, data=feature_health_provider_data) + return response.json()["webhook_url"] + + +@pytest.fixture +def unhealthy_feature( + sample_feature_health_provider_webhook_url: str, + feature_name: str, + feature: int, + api_client: APIClient, +) -> int: + api_client.post( + sample_feature_health_provider_webhook_url, + data=json.dumps({"feature": feature_name, "status": "unhealthy"}), + content_type="application/json", + ) + return feature diff --git a/api/tests/integration/features/feature_health/test_views.py b/api/tests/integration/features/feature_health/test_views.py new file mode 100644 index 000000000000..0d549486d107 --- /dev/null +++ b/api/tests/integration/features/feature_health/test_views.py @@ -0,0 +1,240 @@ +import json +from datetime import datetime, timedelta + +import pytest +from django.urls import reverse +from freezegun import freeze_time +from pytest_mock import MockerFixture +from rest_framework.test import APIClient + +from tests.types import AdminClientAuthType + + +@pytest.fixture +def expected_created_by( + admin_client_auth_type: AdminClientAuthType, + admin_user_email: str, +) -> str | None: + if admin_client_auth_type == "user": + return admin_user_email + return None + + +def test_feature_health_providers__get__expected_response( + project: int, + admin_client_new: APIClient, + expected_created_by: str | None, + mocker: MockerFixture, +) -> None: + # Given + url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) + expected_feature_health_provider_data = admin_client_new.post( + url, + data={"name": "Sample"}, + ).json() + + # When + response = admin_client_new.get(url) + + # Then + assert expected_feature_health_provider_data == { + "created_by": expected_created_by, + "name": "Sample", + "project": project, + "webhook_url": mocker.ANY, + } + assert expected_feature_health_provider_data["webhook_url"].startswith( + "http://testserver/api/v1/feature-health/" + ) + assert response.status_code == 200 + assert response.json() == [expected_feature_health_provider_data] + + +def test_webhook__invalid_path__expected_response( + api_client: APIClient, +) -> None: + # Given + webhook_url = reverse("api-v1:feature-health-webhook", args=["invalid"]) + + # When + response = api_client.post(webhook_url) + + # Then + assert response.status_code == 404 + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_webhook__sample_provider__post__expected_feature_health_event_created__expected_tag_added( + feature: int, + project: int, + feature_name: str, + sample_feature_health_provider_webhook_url: str, + api_client: APIClient, + admin_client_new: APIClient, +) -> None: + # Given + feature_health_events_url = reverse( + "api-v1:projects:feature-health-events-list", args=[project] + ) + tags_url = reverse("api-v1:projects:tags-list", args=[project]) + features_url = reverse("api-v1:projects:project-features-list", args=[project]) + + # When + webhook_data = { + "feature": feature_name, + "status": "unhealthy", + } + response = api_client.post( + sample_feature_health_provider_webhook_url, + data=json.dumps(webhook_data), + content_type="application/json", + ) + + # Then + assert response.status_code == 200 + response = admin_client_new.get(feature_health_events_url) + assert response.json() == [ + { + "created_at": "2023-01-19T09:09:47.325132Z", + "environment": None, + "feature": feature, + "provider_name": "Sample", + "reason": "", + "type": "UNHEALTHY", + } + ] + response = admin_client_new.get(tags_url) + assert ( + tag_data := next( + tag_data + for tag_data in response.json()["results"] + if tag_data.get("label") == "Unhealthy" + ) + ) + response = admin_client_new.get(features_url) + feature_data = next( + feature_data + for feature_data in response.json()["results"] + if feature_data.get("id") == feature + ) + assert tag_data["id"] in feature_data["tags"] + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_webhook__sample_provider__post_with_environment_expected_feature_health_event_created( + feature: int, + project: int, + environment: int, + feature_name: str, + environment_name: str, + sample_feature_health_provider_webhook_url: str, + api_client: APIClient, + admin_client_new: APIClient, +) -> None: + # Given + feature_health_events_url = reverse( + "api-v1:projects:feature-health-events-list", args=[project] + ) + + # When + webhook_data = { + "feature": feature_name, + "environment": environment_name, + "status": "unhealthy", + } + response = api_client.post( + sample_feature_health_provider_webhook_url, + data=json.dumps(webhook_data), + content_type="application/json", + ) + + # Then + assert response.status_code == 200 + response = admin_client_new.get(feature_health_events_url) + assert response.json() == [ + { + "created_at": "2023-01-19T09:09:47.325132Z", + "environment": environment, + "feature": feature, + "provider_name": "Sample", + "reason": "", + "type": "UNHEALTHY", + } + ] + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_webhook__unhealthy_feature__post__expected_feature_health_event_created__expected_tag_removed( + unhealthy_feature: int, + project: int, + feature_name: str, + sample_feature_health_provider_webhook_url: str, + api_client: APIClient, + admin_client_new: APIClient, +) -> None: + # Given + feature_health_events_url = reverse( + "api-v1:projects:feature-health-events-list", args=[project] + ) + tags_url = reverse("api-v1:projects:tags-list", args=[project]) + features_url = reverse("api-v1:projects:project-features-list", args=[project]) + + # When + webhook_data = { + "feature": feature_name, + "status": "healthy", + } + with freeze_time(datetime.now() + timedelta(seconds=1)): + response = api_client.post( + sample_feature_health_provider_webhook_url, + data=json.dumps(webhook_data), + content_type="application/json", + ) + + # Then + assert response.status_code == 200 + response = admin_client_new.get(feature_health_events_url) + assert response.json() == [ + { + "created_at": "2023-01-19T09:09:48.325132Z", + "environment": None, + "feature": unhealthy_feature, + "provider_name": "Sample", + "reason": "", + "type": "HEALTHY", + } + ] + response = admin_client_new.get(tags_url) + assert ( + tag_data := next( + tag_data + for tag_data in response.json()["results"] + if tag_data.get("label") == "Unhealthy" + ) + ) + response = admin_client_new.get(features_url) + feature_data = next( + feature_data + for feature_data in response.json()["results"] + if feature_data.get("id") == unhealthy_feature + ) + assert tag_data["id"] not in feature_data["tags"] + + +@pytest.mark.parametrize( + "body", ["invalid", json.dumps({"status": "unhealthy", "feature": "non_existent"})] +) +def test_webhook__sample_provider__post__invalid_payload__expected_response( + sample_feature_health_provider_webhook_url: str, + api_client: APIClient, + body: str, +) -> None: + # When + response = api_client.post( + sample_feature_health_provider_webhook_url, + data=body, + content_type="application/json", + ) + + # Then + assert response.status_code == 400 diff --git a/api/tests/types.py b/api/tests/types.py index bcc790d8a796..d66431ad21e5 100644 --- a/api/tests/types.py +++ b/api/tests/types.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Callable, Literal from environments.permissions.models import UserEnvironmentPermission from organisations.permissions.models import UserOrganisationPermission @@ -13,3 +13,5 @@ WithEnvironmentPermissionsCallable = Callable[ [list[str] | None, int | None, bool], UserEnvironmentPermission ] + +AdminClientAuthType = Literal["user", "master_api_key"] diff --git a/api/tests/unit/features/feature_health/__init__.py b/api/tests/unit/features/feature_health/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/features/feature_health/conftest.py b/api/tests/unit/features/feature_health/conftest.py new file mode 100644 index 000000000000..292ff69552f0 --- /dev/null +++ b/api/tests/unit/features/feature_health/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from features.feature_health.models import FeatureHealthProvider +from projects.models import Project +from users.models import FFAdminUser + + +@pytest.fixture +def feature_health_provider( + project: Project, + staff_user: FFAdminUser, +) -> FeatureHealthProvider: + return FeatureHealthProvider.objects.create( + created_by=staff_user, + project=project, + name="Sample", + ) diff --git a/api/tests/unit/features/feature_health/test_admin.py b/api/tests/unit/features/feature_health/test_admin.py new file mode 100644 index 000000000000..d2b4b53ec6b1 --- /dev/null +++ b/api/tests/unit/features/feature_health/test_admin.py @@ -0,0 +1,63 @@ +import pytest +from django.http import HttpRequest +from django.test import RequestFactory +from pytest_mock import MockerFixture + +from features.feature_health.admin import FeatureHealthProviderAdmin +from features.feature_health.models import FeatureHealthProvider +from users.models import FFAdminUser + + +@pytest.fixture +def feature_health_provider_admin_request( + admin_user: FFAdminUser, + rf: RequestFactory, +) -> HttpRequest: + request = rf.get("/admin/features/feature_health/featurehealthprovider/") + request.user = admin_user + return request + + +def test_feature_health_provider_admin__changelist_view__expected_request_set( + feature_health_provider_admin_request: HttpRequest, + mocker: MockerFixture, +) -> None: + # Given + admin_instance = FeatureHealthProviderAdmin( + FeatureHealthProvider, mocker.MagicMock() + ) + + # When + admin_instance.changelist_view(feature_health_provider_admin_request) + + # Then + assert admin_instance.request == feature_health_provider_admin_request + + +def test_feature_health_provider_admin__webhook_url__return_expected( + feature_health_provider_admin_request: HttpRequest, + mocker: MockerFixture, +) -> None: + # Given + get_webhook_path_from_provider_mock = mocker.patch( + "features.feature_health.admin.get_webhook_path_from_provider" + ) + get_webhook_path_from_provider_mock.return_value = ( + "/api/v1/feature-health/" + "IjgxZjU1OTU3NjJiMjRlYzNiMmIyY2QzYjA0NzM1YTljIg/OgsuQAL_3ZtIJEoS_L8W0B2Kb1f_1b70wY7IGWOakcs" + ) + admin_instance = FeatureHealthProviderAdmin( + FeatureHealthProvider, mocker.MagicMock() + ) + feature_health_provider = FeatureHealthProvider(name="Sample") + admin_instance.changelist_view(feature_health_provider_admin_request) + + # When + webhook_url = admin_instance.webhook_url(feature_health_provider) + + # Then + get_webhook_path_from_provider_mock.assert_called_once_with(feature_health_provider) + assert ( + webhook_url == "http://testserver/api/v1/feature-health/" + "IjgxZjU1OTU3NjJiMjRlYzNiMmIyY2QzYjA0NzM1YTljIg/OgsuQAL_3ZtIJEoS_L8W0B2Kb1f_1b70wY7IGWOakcs" + ) diff --git a/api/tests/unit/features/feature_health/test_models.py b/api/tests/unit/features/feature_health/test_models.py new file mode 100644 index 000000000000..baa1269fe277 --- /dev/null +++ b/api/tests/unit/features/feature_health/test_models.py @@ -0,0 +1,224 @@ +import datetime + +from freezegun import freeze_time +from pytest_mock import MockerFixture + +from environments.models import Environment +from features.feature_health.models import ( + FeatureHealthEvent, + FeatureHealthProvider, +) +from features.models import Feature +from organisations.models import Organisation +from projects.models import Project +from users.models import FFAdminUser + +now = datetime.datetime.now() + + +def test_feature_health_provider__get_create_log_message__return_expected( + feature_health_provider: FeatureHealthProvider, + mocker: MockerFixture, +) -> None: + # When + log_message = feature_health_provider.get_create_log_message(mocker.Mock()) + + # Then + assert log_message == "Health provider Sample set up for project Test Project." + + +def test_feature_health_provider__get_delete_log_message__return_expected( + feature_health_provider: FeatureHealthProvider, + mocker: MockerFixture, +) -> None: + # When + log_message = feature_health_provider.get_delete_log_message(mocker.Mock()) + + # Then + assert log_message == "Health provider Sample removed from project Test Project." + + +def test_feature_health_provider__get_audit_log_author__return_expected( + feature_health_provider: FeatureHealthProvider, + mocker: MockerFixture, + staff_user: FFAdminUser, +) -> None: + # When + audit_log_author = feature_health_provider.get_audit_log_author(mocker.Mock()) + + # Then + assert audit_log_author == staff_user + + +def test_feature_health_event__get_latest_by_feature__return_expected( + project: Project, + feature: Feature, + environment: Environment, +) -> None: + # Given + unrelated_feature = Feature.objects.create( + project=project, name="unrelated_feature" + ) + environment_2 = Environment.objects.create(project=project, name="Environment 2") + + latest_provider1_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider1", + ) + latest_provider1_environment_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider1", + environment=environment, + ) + with freeze_time(now - datetime.timedelta(hours=1)): + older_provider1_event = FeatureHealthEvent.objects.create( + feature=feature, + type="HEALTHY", + provider_name="provider1", + ) + older_provider1_environment_event = FeatureHealthEvent.objects.create( + feature=feature, + type="HEALTHY", + provider_name="provider1", + environment=environment, + ) + latest_provider_1_environment_2_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider1", + environment=environment_2, + ) + with freeze_time(now - datetime.timedelta(hours=2)): + latest_provider2_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider2", + ) + older_provider_1_environment_2_event = FeatureHealthEvent.objects.create( + feature=feature, + type="HEALTHY", + provider_name="provider1", + environment=environment_2, + ) + unrelated_feature_event = FeatureHealthEvent.objects.create( + feature=unrelated_feature, + type="UNHEALTHY", + provider_name="provider1", + ) + + # When + feature_health_events = [*FeatureHealthEvent.objects.get_latest_by_feature(feature)] + + # Then + assert feature_health_events == [ + latest_provider1_environment_event, + latest_provider_1_environment_2_event, + latest_provider1_event, + latest_provider2_event, + ] + assert older_provider1_event not in feature_health_events + assert older_provider1_environment_event not in feature_health_events + assert older_provider_1_environment_2_event not in feature_health_events + assert unrelated_feature_event not in feature_health_events + + +def test_feature_health_event__get_latest_by_project__return_expected( + project: Project, + feature: Feature, + organisation: Organisation, +) -> None: + # Given + another_feature = Feature.objects.create(project=project, name="another_feature") + unrelated_project = Project.objects.create( + organisation=organisation, name="unrelated_project" + ) + unrelated_feature = Feature.objects.create( + project=unrelated_project, name="unrelated_feature" + ) + + latest_provider1_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider1", + ) + with freeze_time(now - datetime.timedelta(hours=1)): + older_provider1_event = FeatureHealthEvent.objects.create( + feature=feature, + type="HEALTHY", + provider_name="provider1", + ) + with freeze_time(now - datetime.timedelta(hours=2)): + latest_provider2_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider2", + ) + with freeze_time(now - datetime.timedelta(hours=3)): + another_feature_event = FeatureHealthEvent.objects.create( + feature=another_feature, + type="UNHEALTHY", + provider_name="provider1", + ) + unrelated_feature_event = FeatureHealthEvent.objects.create( + feature=unrelated_feature, + type="UNHEALTHY", + provider_name="provider1", + ) + + # When + feature_health_events = [*FeatureHealthEvent.objects.get_latest_by_project(project)] + + # Then + assert feature_health_events == [ + latest_provider1_event, + another_feature_event, + latest_provider2_event, + ] + assert older_provider1_event not in feature_health_events + assert unrelated_feature_event not in feature_health_events + + +def test_feature_health_event__get_create_log_message__return_expected( + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given + feature_health_event = FeatureHealthEvent.objects.create( + feature=feature, + type="UNHEALTHY", + provider_name="provider1", + reason="Test reason", + ) + + # When + log_message = feature_health_event.get_create_log_message(mocker.Mock()) + + # Then + assert ( + log_message == "Health status changed to UNHEALTHY for feature Test Feature1." + "\n\nProvided by provider1\n\nReason:\nTest reason" + ) + + +def test_feature_health_event__get_create_log_message__environment__return_expected( + feature: Feature, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + feature_health_event = FeatureHealthEvent.objects.create( + feature=feature, + environment=environment, + type="UNHEALTHY", + ) + + # When + log_message = feature_health_event.get_create_log_message(mocker.Mock()) + + # Then + assert ( + log_message + == "Health status changed to UNHEALTHY for feature Test Feature1 in environment Test Environment." + ) diff --git a/api/tests/unit/features/feature_health/test_services.py b/api/tests/unit/features/feature_health/test_services.py new file mode 100644 index 000000000000..0f95c9596127 --- /dev/null +++ b/api/tests/unit/features/feature_health/test_services.py @@ -0,0 +1,33 @@ +import uuid + +from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture + +from features.feature_health.services import get_provider_response + + +def test_get_provider_response__invalid_provider__return_none__log_expected( + mocker: MockerFixture, + log: "StructuredLogCapture", +) -> None: + # Given + expected_provider_name = "invalid_provider" + expected_provider_uuid = uuid.uuid4() + + invalid_provider_mock = mocker.MagicMock() + invalid_provider_mock.name = expected_provider_name + invalid_provider_mock.uuid = expected_provider_uuid + + # When + response = get_provider_response(invalid_provider_mock, "payload") + + # Then + assert response is None + assert log.events == [ + { + "event": "invalid-feature-health-provider-requested", + "level": "error", + "provider_id": expected_provider_uuid, + "provider_name": expected_provider_name, + }, + ]