diff --git a/api/features/feature_health/constants.py b/api/features/feature_health/constants.py index d8e298aea83e..9db1a11bb1f1 100644 --- a/api/features/feature_health/constants.py +++ b/api/features/feature_health/constants.py @@ -1,5 +1,5 @@ FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE = "Health provider %s set up for project %s." -FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE = "Health provider %s removed 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 = ( diff --git a/api/tests/integration/features/feature_health/conftest.py b/api/tests/integration/features/feature_health/conftest.py index 81a343b7f3d9..7865206960e1 100644 --- a/api/tests/integration/features/feature_health/conftest.py +++ b/api/tests/integration/features/feature_health/conftest.py @@ -1,3 +1,5 @@ +import json + import pytest from django.urls import reverse from rest_framework.test import APIClient @@ -11,3 +13,18 @@ def sample_feature_health_provider_webhook_url( url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) response = admin_client.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 index db502375f5e2..dff9f4e1426a 100644 --- a/api/tests/integration/features/feature_health/test_views.py +++ b/api/tests/integration/features/feature_health/test_views.py @@ -1,7 +1,9 @@ import json +from datetime import datetime, timedelta import pytest from django.urls import reverse +from freezegun import freeze_time from rest_framework.test import APIClient @@ -75,14 +77,120 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__ 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: 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.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: 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.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.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.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, - body="invalid", + data=body, + content_type="application/json", ) # Then 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_models.py b/api/tests/unit/features/feature_health/test_models.py index d5e675c41709..60f61b562af9 100644 --- a/api/tests/unit/features/feature_health/test_models.py +++ b/api/tests/unit/features/feature_health/test_models.py @@ -1,15 +1,55 @@ import datetime from freezegun import freeze_time +from pytest_mock import MockerFixture -from features.feature_health.models import FeatureHealthEvent +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, @@ -105,3 +145,47 @@ def test_feature_health_event__get_latest_by_project__return_expected( ] 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, + }, + ]