diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 68d6ee0988f6..031f43deae4d 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -174,6 +174,14 @@ def _ld_operator_to_flagsmith_operator(ld_operator: str) -> Optional[ConditionOp }.get(ld_operator, None) +def _to_unanchored_regex(pattern: str) -> str: + # Launch Darkly searches for `matches` patterns anywhere in the value, while Flagsmith matches from the + # start only. Leading inline flags such as `(?i)` must stay at the start of the expression. + flags = re.match(r"(?:\(\?[a-zA-Z]+\))+", pattern) + prefix = flags.group() if flags else "" + return f"{prefix}[\\s\\S]*(?:{pattern[len(prefix) :]})" + + def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]: """ Convert "values" of a Launch Darkly clause to Flagsmith compatible values. Some matching is converted to @@ -201,6 +209,8 @@ def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]: return [".*" + re.escape(value) + "$" for value in values] case "startsWith": return ["^" + re.escape(value) for value in values] + case "matches": + return [_to_unanchored_regex(value) for value in values] case "semVerEqual" | "semVerLessThan" | "semVerGreaterThan": return [value + ":semver" for value in values] case _: diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py index dd76a48a0d81..df22a5ff93e8 100644 --- a/api/tests/unit/integrations/launch_darkly/test_services.py +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -395,7 +395,7 @@ def test_process_import_request__valid_segments__creates_segment_per_environment { "property": "p3", "operator": segment_constants.REGEX, - "value": "foo[0-9]{0,1}", + "value": "[\\s\\S]*(?:foo[0-9]{0,1})", "description": None, } ], @@ -511,6 +511,53 @@ def test_process_import_request__ends_with_clause__matches_suffix_only( assert ("Dynamic List (Override for test)" in segment_names) is expected_in_segment +@pytest.mark.parametrize( + "pattern, email, expected_in_segment", + [ + (r"gmail\.com", "gmail.com", True), + (r"gmail\.com", "user@gmail.com", True), + (r"gmail\.com", "user@example.org", False), + (r"^gmail|corp", "user@corp.com", True), + (r"^gmail|corp", "user@gmail.com", False), + (r"(?i)GMAIL\.com", "user@gmail.com", True), + (r"corp", "first\nuser@corp.com", True), + (r"^corp", "first\ncorp", False), + ], +) +@pytest.mark.django_db(transaction=True) +def test_process_import_request__matches_clause__matches_anywhere_in_value( + project: Project, + import_request: LaunchDarklyImportRequest, + ld_client_mock: MagicMock, + pattern: str, + email: str, + expected_in_segment: bool, +) -> None: + # Given + segments = [ + segment + for segment in ld_client_mock.get_segments.return_value + if segment["key"] == "dynamic-list" + ] + segments[0]["rules"][0]["clauses"][0].update(op="matches", values=[pattern]) + ld_client_mock.get_segments.return_value = segments + process_import_request(import_request) + environment = Environment.objects.get(project=project, name="Test") + identity = Identity.objects.create(identifier="user", environment=environment) + Trait.objects.create( + identity=identity, + trait_key="email", + value_type="unicode", + string_value=email, + ) + + # When + segment_names = {segment.name for segment in identity.get_segments()} + + # Then + assert ("Dynamic List (Override for test)" in segment_names) is expected_in_segment + + @pytest.mark.django_db(transaction=True) def test_process_import_request__valid_segments__creates_identities_with_key_traits( project: Project, @@ -648,7 +695,7 @@ def test_process_import_request__valid_segments__imports_correctly_x_replaced_ab "property", "operator", "value" ) ) == { - ("p3", segment_constants.REGEX, "foo[0-9]{0,1}"), + ("p3", segment_constants.REGEX, "[\\s\\S]*(?:foo[0-9]{0,1})"), } # Include individual users