Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions api/features/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,51 @@ def _get_previous_state(
instance: FeatureState,
history_instance: HistoricalFeatureState,
event_type: WebhookEventType,
) -> dict: # type: ignore[type-arg]
) -> dict[str, Any] | None:
if event_type == WebhookEventType.FLAG_DELETED:
return _get_feature_state_webhook_data(instance)

# Change requests create a new FeatureState with its own history
if instance.change_request_id is not None:
previous_fs = _get_previous_feature_state_for_change_request(instance)
if previous_fs:
return _get_feature_state_webhook_data(previous_fs)
return None

if history_instance and history_instance.prev_record:
return _get_feature_state_webhook_data(
history_instance.prev_record.instance, previous=True
)
return None # type: ignore[return-value]
return None


def _get_previous_feature_state_for_change_request(
instance: FeatureState,
) -> FeatureState | None:
"""Find the previous live FeatureState for a change request (legacy versioning)."""
result: FeatureState | None = (
FeatureState.objects.exclude(
change_request_id=instance.change_request_id,
)
.filter(
environment_id=instance.environment_id,
feature_id=instance.feature_id,
feature_segment=instance.feature_segment,
identity=instance.identity,
version__isnull=False,
live_from__lt=instance.live_from,
)
.order_by("-live_from")
.select_related(
"feature",
"environment",
"feature_state_value",
"feature_segment",
"identity",
)
.first()
)
return result


def _get_feature_state_webhook_data(
Expand Down
119 changes: 119 additions & 0 deletions api/tests/integration/features/featurestate/test_webhooks.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
import json
from collections.abc import Generator
from typing import Any

import pytest
import responses
from django.urls import reverse
from freezegun import freeze_time
from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped]
from pytest_mock import MockerFixture
from rest_framework import status
from rest_framework.test import APIClient

from audit.tasks import create_feature_state_went_live_audit_log
from features.models import FeatureState
from features.workflows.core.models import ChangeRequest
from users.models import FFAdminUser

WEBHOOK_URL = "https://example.com/webhook"


def _extract_webhook_payloads(event_type: str | None) -> Generator[dict[str, Any]]:
"""Extract webhook payloads from responses cache"""
return (
payload
for payload in (
json.loads(r.request.body)
for r in responses.calls
if r.request.url == WEBHOOK_URL
)
if event_type is None or payload["event_type"] == event_type
)


@pytest.fixture
def environment_webhook(
admin_client: APIClient,
Expand Down Expand Up @@ -218,3 +239,101 @@ def test_update_multivariate_percentage__webhook_payload_includes_multivariate_v
"percentage_allocation": old_percentage,
},
]


@pytest.mark.parametrize(
"webhook",
[lazy_fixture("environment_webhook"), lazy_fixture("organisation_webhook")],
)
@responses.activate
def test_update_feature_live__legacy_versioning__webhook_payload_has_correct_previous_and_new_states(
admin_client: APIClient,
environment: int,
feature: int,
webhook: str,
) -> None:
# Given
feature_state_json = admin_client.get(
f"/api/v1/features/featurestates/?environment={environment}&feature={feature}"
).json()["results"][0]
feature_state_id = feature_state_json["id"]
previous_state = feature_state_json["enabled"]
previous_value = feature_state_json["feature_state_value"]["string_value"]

# When
responses.add(responses.POST, webhook, status=200)
response = admin_client.put(
f"/api/v1/features/featurestates/{feature_state_id}/",
data={
**feature_state_json,
"feature_state_value": {"string_value": "new_value"},
"enabled": True,
},
format="json",
)
assert response.status_code == 200

# Then
payload = list(_extract_webhook_payloads("FLAG_UPDATED"))[-1]
assert payload["data"]["previous_state"]["enabled"] is previous_state
assert payload["data"]["previous_state"]["feature_state_value"] == previous_value
assert payload["data"]["new_state"]["enabled"] is True
assert payload["data"]["new_state"]["feature_state_value"] == "new_value"


@pytest.mark.parametrize(
"webhook",
[lazy_fixture("environment_webhook"), lazy_fixture("organisation_webhook")],
)
@responses.activate
def test_update_feature_scheduled__legacy_versioning__webhook_payload_has_correct_previous_and_new_states(
admin_user: FFAdminUser,
environment: int,
feature: int,
webhook: str,
) -> None:
"""Covers https://github.com/Flagsmith/flagsmith/issues/2063"""
# Given
previous_feature_state = FeatureState.objects.get(feature_id=feature)
previous_state = previous_feature_state.enabled
previous_value = previous_feature_state.get_feature_state_value()

# Simulate scheduled update in the frontend
with freeze_time("2048-02-29T02:00:00+0000"):
# POST create-change-request
change_request = ChangeRequest.objects.create(
environment=previous_feature_state.environment,
title="Scheduled update",
user=admin_user,
)
new_feature_state = FeatureState.objects.create(
environment=previous_feature_state.environment,
feature=previous_feature_state.feature,
change_request=change_request,
enabled=True,
live_from="2048-02-29T06:00:00+0000",
version=None,
)
new_feature_state.feature_state_value.string_value = "new_value"
new_feature_state.feature_state_value.save()

with freeze_time("2048-02-29T02:00:01+0000"):
# PUT workflows:change-requests-detail pk={change_request.id}
new_feature_state.save()
new_feature_state.feature_state_value.save()

responses.calls.reset()

# When
responses.add(responses.POST, webhook, status=200)
change_request.commit(committed_by=admin_user)
assert not any(_extract_webhook_payloads("FLAG_UPDATED"))
with freeze_time("2048-02-29T06:00:00+0000"):
create_feature_state_went_live_audit_log(new_feature_state.id)

# Then
payload = list(_extract_webhook_payloads("FLAG_UPDATED"))[-1]
assert payload["data"]["previous_state"]["enabled"] is previous_state
assert payload["data"]["previous_state"]["feature_state_value"] == previous_value
assert payload["data"]["new_state"]["enabled"] is True
assert payload["data"]["new_state"]["feature_state_value"] == "new_value"
Loading