feat(nimbus): Validate desktop features with versioned manifests (#9858)
Because - we want to validate feature values against versioned manifests This commit - adds the machinery to determine which NimbusVersionedSchemas to validate against; and - validates Desktop experiments against those schemas. Fixes #9810
This commit is contained in:
Родитель
80fa3a3164
Коммит
835e1d26ba
|
@ -4,8 +4,10 @@ import re
|
|||
import typing
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import jsonschema
|
||||
import packaging
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
|
@ -31,6 +33,7 @@ from experimenter.experiments.models import (
|
|||
NimbusDocumentationLink,
|
||||
NimbusExperiment,
|
||||
NimbusFeatureConfig,
|
||||
NimbusFeatureVersion,
|
||||
NimbusVersionedSchema,
|
||||
)
|
||||
from experimenter.features.manifests.nimbus_fml_loader import NimbusFmlLoader
|
||||
|
@ -1420,31 +1423,70 @@ class NimbusReviewSerializer(serializers.ModelSerializer):
|
|||
return value
|
||||
|
||||
def _validate_feature_value_against_schema(
|
||||
self, feature_config, value, localizations
|
||||
self,
|
||||
feature_config: NimbusFeatureConfig,
|
||||
value: str,
|
||||
localizations: Optional[dict[str, Any]],
|
||||
min_version: packaging.version.Version,
|
||||
max_version: Optional[packaging.version.Version],
|
||||
):
|
||||
if schema := feature_config.schemas.get(version=None).schema:
|
||||
json_schema = json.loads(schema)
|
||||
json_value = json.loads(value)
|
||||
schemas_in_range = feature_config.get_versioned_schema_range(
|
||||
min_version, max_version
|
||||
)
|
||||
if schemas_in_range.unsupported_in_range:
|
||||
return [
|
||||
NimbusConstants.ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_RANGE.format(
|
||||
feature_config=feature_config,
|
||||
)
|
||||
]
|
||||
|
||||
errors = []
|
||||
|
||||
for version in schemas_in_range.unsupported_versions:
|
||||
errors.append(
|
||||
NimbusConstants.ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_VERSION.format(
|
||||
feature_config=feature_config,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
|
||||
json_value = json.loads(value)
|
||||
|
||||
for schema in schemas_in_range.schemas:
|
||||
if schema.schema is None: # Only in tests.
|
||||
continue
|
||||
|
||||
json_schema = json.loads(schema.schema)
|
||||
if not localizations:
|
||||
return self._validate_schema(json_value, json_schema)
|
||||
errors.extend(
|
||||
self._validate_schema(json_value, json_schema, schema.version)
|
||||
)
|
||||
else:
|
||||
for locale_code, substitutions in localizations.items():
|
||||
try:
|
||||
substituted_value = self._substitute_localizations(
|
||||
json_value, substitutions, locale_code
|
||||
)
|
||||
except LocalizationError as e:
|
||||
errors.append(str(e))
|
||||
continue
|
||||
|
||||
for locale_code, substitutions in localizations.items():
|
||||
try:
|
||||
substituted_value = self._substitute_localizations(
|
||||
json_value, substitutions, locale_code
|
||||
)
|
||||
except LocalizationError as e:
|
||||
return [str(e)]
|
||||
|
||||
if schema_errors := self._validate_schema(substituted_value, json_schema):
|
||||
return [
|
||||
(
|
||||
if schema_errors := self._validate_schema(
|
||||
substituted_value, json_schema, schema.version
|
||||
):
|
||||
err_msg = (
|
||||
f"Schema validation errors occured during locale "
|
||||
f"substitution for locale {locale_code}"
|
||||
),
|
||||
*schema_errors,
|
||||
]
|
||||
)
|
||||
|
||||
if schema.version is not None:
|
||||
err_msg += f" at version {schema.version}"
|
||||
|
||||
errors.append(err_msg)
|
||||
errors.extend(schema_errors)
|
||||
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
return None
|
||||
|
||||
|
@ -1458,15 +1500,35 @@ class NimbusReviewSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
return None
|
||||
|
||||
def _validate_schema(self, obj, schema):
|
||||
def _validate_schema(
|
||||
self, obj: Any, schema: dict[str, Any], version: Optional[NimbusFeatureVersion]
|
||||
) -> list[str]:
|
||||
try:
|
||||
jsonschema.validate(obj, schema, resolver=NestedRefResolver(schema))
|
||||
except jsonschema.ValidationError as e:
|
||||
return [e.message]
|
||||
err_msg = e.message
|
||||
if version is not None:
|
||||
err_msg += f" at version {version}"
|
||||
|
||||
return [err_msg]
|
||||
|
||||
return []
|
||||
|
||||
def _validate_feature_configs(self, data):
|
||||
application = data.get("application")
|
||||
feature_configs = data.get("feature_configs", [])
|
||||
|
||||
min_version = None
|
||||
max_version = None
|
||||
if not NimbusExperiment.Application.is_web(application):
|
||||
raw_min_version = data.get("firefox_min_version", "")
|
||||
raw_max_version = data.get("firefox_max_version", "")
|
||||
|
||||
# We've already validated the versions in _validate_versions.
|
||||
min_version = NimbusExperiment.Version.parse(raw_min_version)
|
||||
if raw_max_version:
|
||||
max_version = NimbusExperiment.Version.parse(raw_max_version)
|
||||
|
||||
warn_feature_schema = data.get("warn_feature_schema", False)
|
||||
|
||||
errors = {
|
||||
|
@ -1496,6 +1558,8 @@ class NimbusReviewSerializer(serializers.ModelSerializer):
|
|||
feature_value_data["feature_config"],
|
||||
feature_value_data["value"],
|
||||
localizations,
|
||||
min_version,
|
||||
max_version,
|
||||
):
|
||||
reference_branch_errors.append({"value": schema_errors})
|
||||
continue
|
||||
|
@ -1524,6 +1588,8 @@ class NimbusReviewSerializer(serializers.ModelSerializer):
|
|||
feature_value_data["feature_config"],
|
||||
feature_value_data["value"],
|
||||
localizations,
|
||||
min_version,
|
||||
max_version,
|
||||
):
|
||||
treatment_branch_errors.append({"value": schema_errors})
|
||||
treatment_branches_errors_found = True
|
||||
|
@ -1906,9 +1972,9 @@ class NimbusReviewSerializer(serializers.ModelSerializer):
|
|||
{"channel": "Channel is required for this application."}
|
||||
)
|
||||
data = super().validate(data)
|
||||
data = self._validate_versions(data)
|
||||
data = self._validate_localizations(data)
|
||||
data = self._validate_feature_configs(data)
|
||||
data = self._validate_versions(data)
|
||||
data = self._validate_enrollment_targeting(data)
|
||||
data = self._validate_sticky_enrollment(data)
|
||||
data = self._validate_rollout_version_support(data)
|
||||
|
|
|
@ -502,6 +502,14 @@ class NimbusConstants:
|
|||
Application.DESKTOP: Version.FIREFOX_113,
|
||||
}
|
||||
|
||||
MIN_VERSIONED_FEATURE_VERSION = {
|
||||
Application.DESKTOP: Version.FIREFOX_120,
|
||||
Application.FENIX: Version.FIREFOX_116,
|
||||
Application.FOCUS_ANDROID: Version.FIREFOX_116,
|
||||
Application.IOS: Version.FIREFOX_116,
|
||||
Application.FOCUS_IOS: Version.FIREFOX_116,
|
||||
}
|
||||
|
||||
# Telemetry systems including Firefox Desktop Telemetry v4 and Glean
|
||||
# have limits on the length of their unique identifiers, we should
|
||||
# limit the size of our slugs to the smallest limit, which is 80
|
||||
|
@ -617,3 +625,11 @@ Optional - We believe this outcome will <describe impact> on <core metric>
|
|||
ERROR_MULTIFEATURE_TOO_MANY_FEATURES = (
|
||||
"Multi-feature experiments can only support up to 20 different features."
|
||||
)
|
||||
|
||||
ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_RANGE = (
|
||||
"Feature {feature_config} is not supported by any version in this range."
|
||||
)
|
||||
|
||||
ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_VERSION = (
|
||||
"Feature {feature_config} is not supported in version {version}."
|
||||
)
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.2.23 on 2023-11-29 17:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("experiments", "0251_nimbusfeatureversion"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="nimbusversionedschema",
|
||||
name="version",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="schemas",
|
||||
to="experiments.nimbusfeatureversion",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -2,11 +2,13 @@ import copy
|
|||
import datetime
|
||||
import os.path
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin
|
||||
from uuid import uuid4
|
||||
|
||||
import packaging
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
@ -15,7 +17,7 @@ from django.core.files.base import ContentFile
|
|||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import F, Q, QuerySet
|
||||
from django.db.models.constraints import UniqueConstraint
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
@ -1236,6 +1238,119 @@ class NimbusFeatureConfig(models.Model):
|
|||
owner_email = models.EmailField(blank=True, null=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
def schemas_between_versions(
|
||||
self,
|
||||
min_version: packaging.version,
|
||||
max_version: Optional[packaging.version],
|
||||
) -> QuerySet["NimbusVersionedSchema"]:
|
||||
return (
|
||||
self.schemas.filter(
|
||||
NimbusFeatureVersion.objects.between_versions_q(
|
||||
min_version, max_version, prefix="version"
|
||||
)
|
||||
)
|
||||
.order_by("-version__major", "-version__minor", "-version__patch")
|
||||
.select_related("version")
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class VersionedSchemaRange:
|
||||
# The versioned schemas in the requested range, or a single element list
|
||||
# with an unversioned schema.
|
||||
schemas: list["NimbusVersionedSchema"]
|
||||
|
||||
# If true, then this feature is unsupported in the entire version range.
|
||||
unsupported_in_range: bool
|
||||
|
||||
# Any versions in the requested range that do not support the schema.
|
||||
unsupported_versions: list["NimbusFeatureVersion"]
|
||||
|
||||
def get_versioned_schema_range(
|
||||
self,
|
||||
min_version: packaging.version,
|
||||
max_version: Optional[packaging.version],
|
||||
) -> VersionedSchemaRange:
|
||||
unsupported_versions: list[NimbusFeatureVersion] = []
|
||||
|
||||
assume_unversioned = False
|
||||
if min_supported_version := NimbusConstants.MIN_VERSIONED_FEATURE_VERSION.get(
|
||||
self.application
|
||||
):
|
||||
min_supported_version = NimbusExperiment.Version.parse(min_supported_version)
|
||||
|
||||
if min_supported_version > min_version:
|
||||
if max_version is not None and min_supported_version >= max_version:
|
||||
# We will not have any NimbusVerionedSchemas in this
|
||||
# version range. The best we can do is use the
|
||||
# unversioned schema.
|
||||
#
|
||||
# TODO(#9869): warn the user that we don't have information
|
||||
# about this interval.
|
||||
assume_unversioned = True
|
||||
elif max_version is None or min_supported_version < max_version:
|
||||
# If you're targeting a minimum version before we have
|
||||
# versioned manifests without an upper bound, we'll use the
|
||||
# min_supported_version as the minimum version for determing
|
||||
# what NimbusVersionedSchemas to use.
|
||||
#
|
||||
# Using the unversioned schema in this case makes less
|
||||
# sense, because we have *some* version information.
|
||||
#
|
||||
# TODO(#9869): warn the user that we don't have information
|
||||
# about this interval.
|
||||
min_version = min_supported_version
|
||||
else:
|
||||
# This application does not support versioned feature configurations.
|
||||
assume_unversioned = True
|
||||
|
||||
if assume_unversioned:
|
||||
schemas = [self.schemas.get(version=None)]
|
||||
else:
|
||||
schemas = list(self.schemas_between_versions(min_version, max_version))
|
||||
|
||||
if schemas:
|
||||
# Find all NimbusFeatureVersion objects between the min and max
|
||||
# version that are supported by *any* feature in the
|
||||
# application.
|
||||
#
|
||||
# If there is a version in this queryse that isn't present in
|
||||
# `schemas`, then we know that the feature is not supported in
|
||||
# that version.
|
||||
supported_versions = (
|
||||
NimbusFeatureVersion.objects.filter(
|
||||
NimbusFeatureVersion.objects.between_versions_q(
|
||||
min_version, max_version
|
||||
),
|
||||
schemas__feature_config__application=self.application,
|
||||
)
|
||||
.order_by("-major", "-minor", "-patch")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
schemas_by_version = {schema.version: schema for schema in schemas}
|
||||
|
||||
for application_version in supported_versions:
|
||||
if application_version not in schemas_by_version:
|
||||
unsupported_versions.append(application_version)
|
||||
elif self.schemas.filter(version__isnull=False).exists():
|
||||
# There are versioned schemas outside this range. This feature
|
||||
# is unsupported in this range.
|
||||
return NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[],
|
||||
unsupported_in_range=True,
|
||||
unsupported_versions=[],
|
||||
)
|
||||
else:
|
||||
# There are no verioned schemas for this feature. Fall back to
|
||||
# using unversioned schema.
|
||||
schemas = [self.schemas.get(version=None)]
|
||||
|
||||
return NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=schemas,
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=unsupported_versions,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Nimbus Feature Config"
|
||||
verbose_name_plural = "Nimbus Feature Configs"
|
||||
|
@ -1245,11 +1360,58 @@ class NimbusFeatureConfig(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
class NimbusFeatureVersionManager(models.Manager["NimbusFeatureVersion"]):
|
||||
def between_versions_q(
|
||||
self,
|
||||
min_version: packaging.version.Version,
|
||||
max_version: Optional[packaging.version.Version],
|
||||
*,
|
||||
prefix: Optional[str] = None,
|
||||
) -> Q:
|
||||
if prefix is not None:
|
||||
|
||||
def prefixed(**kwargs: dict[str, Any]):
|
||||
return {f"{prefix}__{k}": v for k, v in kwargs.items()}
|
||||
|
||||
else:
|
||||
|
||||
def prefixed(**kwargs: dict[str, Any]):
|
||||
return kwargs
|
||||
|
||||
# (a, b, c) >= (d, e, f)
|
||||
# := (a > b) | (a = b & d > e) | (a = b & d = e & c >= f)
|
||||
# == (a > b) | (a = b & (d > e | (d = e & c >= f)))
|
||||
|
||||
# packaging.version.Version uses major.minor.micro, but
|
||||
# NimbusFeatureVersion uses major.minor.patch (semver).
|
||||
q = Q(**prefixed(major__gt=min_version.major)) | Q(
|
||||
**prefixed(major=min_version.major)
|
||||
) & (
|
||||
Q(**prefixed(minor__gt=min_version.minor))
|
||||
| Q(**prefixed(minor=min_version.minor, patch__gte=min_version.micro))
|
||||
)
|
||||
|
||||
if max_version is not None:
|
||||
# (a, b, c) < (d, e, f)
|
||||
# := (a < d) | (a == d & b < e) | (a == d & b == e & c < f)
|
||||
# == (a < d) | (a == d & (b < e | (b == e & c < f)))
|
||||
q &= Q(**prefixed(major__lt=max_version.major)) | Q(
|
||||
**prefixed(major=max_version.major)
|
||||
) & (
|
||||
Q(**prefixed(minor__lt=max_version.minor))
|
||||
| Q(**prefixed(minor=max_version.minor, patch__lt=max_version.micro))
|
||||
)
|
||||
|
||||
return q
|
||||
|
||||
|
||||
class NimbusFeatureVersion(models.Model):
|
||||
major = models.IntegerField(null=False)
|
||||
minor = models.IntegerField(null=False)
|
||||
patch = models.IntegerField(null=False)
|
||||
|
||||
objects = NimbusFeatureVersionManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Nimbus Feature Version"
|
||||
verbose_name_plural = "Nimbus Feature Versions"
|
||||
|
@ -1268,7 +1430,12 @@ class NimbusVersionedSchema(models.Model):
|
|||
related_name="schemas",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
version = models.ForeignKey(NimbusFeatureVersion, on_delete=models.CASCADE, null=True)
|
||||
version = models.ForeignKey(
|
||||
NimbusFeatureVersion,
|
||||
related_name="schemas",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
)
|
||||
schema = models.TextField(blank=True, null=True)
|
||||
sets_prefs = ArrayField(models.CharField(max_length=255, null=False, default=list))
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ from experimenter.experiments.api.v5.serializers import (
|
|||
NimbusReviewSerializer,
|
||||
)
|
||||
from experimenter.experiments.constants import NimbusConstants
|
||||
from experimenter.experiments.models import NimbusExperiment
|
||||
from experimenter.experiments.models import NimbusExperiment, NimbusFeatureVersion
|
||||
from experimenter.experiments.tests.api.v5.test_serializers.mixins import (
|
||||
MockFmlErrorMixin,
|
||||
)
|
||||
|
@ -924,6 +924,7 @@ class TestNimbusReviewSerializerSingleFeature(MockFmlErrorMixin, TestCase):
|
|||
status=NimbusExperiment.Status.DRAFT,
|
||||
application=NimbusExperiment.Application.FENIX,
|
||||
channel=NimbusExperiment.Channel.RELEASE,
|
||||
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
|
||||
feature_configs=[
|
||||
NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.IOS,
|
||||
|
@ -1146,6 +1147,7 @@ class TestNimbusReviewSerializerSingleFeature(MockFmlErrorMixin, TestCase):
|
|||
],
|
||||
)
|
||||
],
|
||||
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
|
||||
is_sticky=True,
|
||||
)
|
||||
reference_feature_value = experiment.reference_branch.feature_values.get()
|
||||
|
@ -2757,6 +2759,435 @@ class TestNimbusReviewSerializerSingleFeature(MockFmlErrorMixin, TestCase):
|
|||
)
|
||||
|
||||
|
||||
class VersionedFeatureValidationTests(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user = UserFactory()
|
||||
|
||||
self.versions = {
|
||||
(v.major, v.minor, v.patch): v
|
||||
for v in NimbusFeatureVersion.objects.bulk_create(
|
||||
NimbusFeatureVersion(major=major, minor=minor, patch=patch)
|
||||
for (major, minor, patch) in (
|
||||
(120, 0, 0),
|
||||
(121, 0, 0),
|
||||
(122, 0, 0),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
(
|
||||
NimbusExperiment.Version.FIREFOX_120,
|
||||
NimbusExperiment.Version.NO_VERSION,
|
||||
NimbusConstants.ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_VERSION.format(
|
||||
feature_config="FEATURE",
|
||||
version="121.0.0",
|
||||
),
|
||||
),
|
||||
(
|
||||
NimbusExperiment.Version.FIREFOX_121,
|
||||
NimbusExperiment.Version.FIREFOX_122,
|
||||
NimbusConstants.ERROR_FEATURE_CONFIG_UNSUPPORTED_IN_RANGE.format(
|
||||
feature_config="FEATURE",
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
def test_validate_feature_versioned_unsupported_versions(
|
||||
self, min_version, max_version, expected_error
|
||||
):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
slug="FEATURE",
|
||||
name="FEATURE",
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(version=None, schema=None),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)], schema=None
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(122, 0, 0)], schema=None
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(version=None, schema=None),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)], schema=None
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(121, 0, 0)], schema=None
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(122, 0, 0)], schema=None
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
experiment = NimbusExperimentFactory.create_with_lifecycle(
|
||||
NimbusExperimentFactory.Lifecycles.CREATED,
|
||||
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
firefox_min_version=min_version,
|
||||
firefox_max_version=max_version,
|
||||
feature_configs=[feature],
|
||||
)
|
||||
|
||||
for branch in experiment.treatment_branches:
|
||||
branch.delete()
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
experiment,
|
||||
data=NimbusReviewSerializer(experiment, context={"user": self.user}).data,
|
||||
context={"user", self.user},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"reference_branch": {
|
||||
"feature_values": [
|
||||
{
|
||||
"value": [expected_error],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
(
|
||||
NimbusExperiment.Version.FIREFOX_110,
|
||||
NimbusExperiment.Version.NO_VERSION,
|
||||
[(122, 0, 0), (121, 0, 0), (120, 0, 0)],
|
||||
),
|
||||
(
|
||||
NimbusExperiment.Version.FIREFOX_110,
|
||||
NimbusExperiment.Version.FIREFOX_121,
|
||||
[(120, 0, 0)],
|
||||
),
|
||||
]
|
||||
)
|
||||
def test_validate_feature_versioned_truncated_range(
|
||||
self, min_version, max_version, expected_versions
|
||||
):
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
slug="FEATURE",
|
||||
name="FEATURE",
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(version=None, schema=None),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)],
|
||||
schema=schema,
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(121, 0, 0)],
|
||||
schema=schema,
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(122, 0, 0)],
|
||||
schema=schema,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
experiment = NimbusExperimentFactory.create_with_lifecycle(
|
||||
NimbusExperimentFactory.Lifecycles.CREATED,
|
||||
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
firefox_min_version=min_version,
|
||||
firefox_max_version=max_version,
|
||||
feature_configs=[feature],
|
||||
)
|
||||
|
||||
for branch in experiment.treatment_branches:
|
||||
branch.delete()
|
||||
|
||||
feature_value = experiment.reference_branch.feature_values.get(
|
||||
feature_config=feature,
|
||||
)
|
||||
feature_value.value = json.dumps({"enabled": 1})
|
||||
feature_value.save()
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
experiment,
|
||||
data=NimbusReviewSerializer(experiment, context={"user": self.user}).data,
|
||||
context={"user", self.user},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"reference_branch": {
|
||||
"feature_values": [
|
||||
{
|
||||
"value": [
|
||||
"1 is not of type 'boolean' at version "
|
||||
f"{major}.{minor}.{patch}"
|
||||
for (major, minor, patch) in expected_versions
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_feature_versioned_before_versioned_range(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
slug="FEATURE",
|
||||
name="FEATURE",
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=None,
|
||||
schema=json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"kind": {"const": "unversioned"}},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)],
|
||||
schema=json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"kind": {"const": "versioned"}},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
experiment = NimbusExperimentFactory.create_with_lifecycle(
|
||||
NimbusExperimentFactory.Lifecycles.CREATED,
|
||||
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
firefox_min_version=NimbusExperiment.Version.FIREFOX_110,
|
||||
firefox_max_version=NimbusExperiment.Version.FIREFOX_111,
|
||||
feature_configs=[feature],
|
||||
)
|
||||
|
||||
for branch in experiment.treatment_branches:
|
||||
branch.delete()
|
||||
|
||||
feature_value = experiment.reference_branch.feature_values.get(
|
||||
feature_config=feature
|
||||
)
|
||||
feature_value.value = json.dumps({"kind": "unversioned"})
|
||||
feature_value.save()
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
experiment,
|
||||
data=NimbusReviewSerializer(experiment, context={"user": self.user}).data,
|
||||
context={"user": self.user},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
def test_validate_feature_versioned(self):
|
||||
json_schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
slug="FEATURE",
|
||||
name="FEATURE",
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(version=None, schema=None),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)], schema=json_schema
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(121, 0, 0)], schema=json_schema
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
experiment = NimbusExperimentFactory.create_with_lifecycle(
|
||||
NimbusExperimentFactory.Lifecycles.CREATED,
|
||||
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
firefox_min_version=NimbusExperiment.Version.FIREFOX_120,
|
||||
firefox_max_version=NimbusExperiment.Version.FIREFOX_122,
|
||||
feature_configs=[feature],
|
||||
)
|
||||
|
||||
for branch in experiment.treatment_branches:
|
||||
branch.delete()
|
||||
|
||||
feature_value = experiment.reference_branch.feature_values.get(
|
||||
feature_config=feature
|
||||
)
|
||||
feature_value.value = json.dumps({"enabled": 1})
|
||||
feature_value.save()
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
experiment,
|
||||
data=NimbusReviewSerializer(experiment, context={"user": self.user}).data,
|
||||
context={"user", self.user},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"reference_branch": {
|
||||
"feature_values": [
|
||||
{
|
||||
"value": [
|
||||
"1 is not of type 'boolean' at version 121.0.0",
|
||||
"1 is not of type 'boolean' at version 120.0.0",
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_feature_versioned_localized(self):
|
||||
json_schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
slug="FEATURE",
|
||||
name="FEATURE",
|
||||
schemas=[
|
||||
NimbusVersionedSchemaFactory.build(version=None, schema=None),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(120, 0, 0)], schema=json_schema
|
||||
),
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
version=self.versions[(121, 0, 0)], schema=json_schema
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
locale_en_us = LocaleFactory.create(code="en-US")
|
||||
locale_en_ca = LocaleFactory.create(code="en-CA")
|
||||
|
||||
experiment = NimbusExperimentFactory.create_with_lifecycle(
|
||||
NimbusExperimentFactory.Lifecycles.CREATED,
|
||||
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
|
||||
application=NimbusExperiment.Application.DESKTOP,
|
||||
firefox_min_version=NimbusExperiment.Version.FIREFOX_120,
|
||||
firefox_max_version=NimbusExperiment.Version.FIREFOX_122,
|
||||
feature_configs=[feature],
|
||||
locales=[locale_en_us, locale_en_ca],
|
||||
is_localized=True,
|
||||
localizations=json.dumps(
|
||||
{
|
||||
"en-US": {"enabled-value": "true"},
|
||||
"en-CA": {"enabled-value": "true"},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
for branch in experiment.treatment_branches:
|
||||
branch.delete()
|
||||
|
||||
feature_value = experiment.reference_branch.feature_values.get(
|
||||
feature_config=feature
|
||||
)
|
||||
feature_value.value = json.dumps(
|
||||
{
|
||||
"enabled": {
|
||||
"$l10n": {
|
||||
"id": "enabled-value",
|
||||
"text": "enabled",
|
||||
"comment": "comment",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
feature_value.save()
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
experiment,
|
||||
data=NimbusReviewSerializer(experiment, context={"user": self.user}).data,
|
||||
context={"user", self.user},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"reference_branch": {
|
||||
"feature_values": [
|
||||
{
|
||||
"value": [
|
||||
(
|
||||
"Schema validation errors occured during locale "
|
||||
"substitution for locale en-US at version 121.0.0"
|
||||
),
|
||||
"'true' is not of type 'boolean' at version 121.0.0",
|
||||
(
|
||||
"Schema validation errors occured during locale "
|
||||
"substitution for locale en-CA at version 121.0.0"
|
||||
),
|
||||
"'true' is not of type 'boolean' at version 121.0.0",
|
||||
(
|
||||
"Schema validation errors occured during locale "
|
||||
"substitution for locale en-US at version 120.0.0"
|
||||
),
|
||||
"'true' is not of type 'boolean' at version 120.0.0",
|
||||
(
|
||||
"Schema validation errors occured during locale "
|
||||
"substitution for locale en-CA at version 120.0.0"
|
||||
),
|
||||
"'true' is not of type 'boolean' at version 120.0.0",
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestNimbusReviewSerializerMultiFeature(MockFmlErrorMixin, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
@ -2810,7 +3241,7 @@ class TestNimbusReviewSerializerMultiFeature(MockFmlErrorMixin, TestCase):
|
|||
),
|
||||
],
|
||||
is_sticky=True,
|
||||
firefox_min_version=NimbusExperiment.Version.FIREFOX_94,
|
||||
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
|
||||
)
|
||||
|
||||
serializer = NimbusReviewSerializer(
|
||||
|
|
|
@ -4,6 +4,7 @@ from decimal import Decimal
|
|||
from itertools import product
|
||||
from unittest import mock
|
||||
|
||||
import packaging
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
|
@ -27,7 +28,9 @@ from experimenter.experiments.models import (
|
|||
NimbusBucketRange,
|
||||
NimbusExperiment,
|
||||
NimbusFeatureConfig,
|
||||
NimbusFeatureVersion,
|
||||
NimbusIsolationGroup,
|
||||
NimbusVersionedSchema,
|
||||
)
|
||||
from experimenter.experiments.tests import JEXLParser
|
||||
from experimenter.experiments.tests.factories import (
|
||||
|
@ -38,6 +41,7 @@ from experimenter.experiments.tests.factories import (
|
|||
NimbusExperimentFactory,
|
||||
NimbusFeatureConfigFactory,
|
||||
NimbusIsolationGroupFactory,
|
||||
NimbusVersionedSchemaFactory,
|
||||
)
|
||||
from experimenter.features import Features
|
||||
from experimenter.features.tests import mock_valid_features
|
||||
|
@ -2846,3 +2850,236 @@ class TestNimbusBranchScreenshot(TestCase):
|
|||
self.screenshot.save()
|
||||
self.screenshot.delete()
|
||||
mock_delete.assert_called_with(expected_filename)
|
||||
|
||||
|
||||
class NimbusFeatureConfigTests(TestCase):
|
||||
def test_schemas_between_versions(self):
|
||||
feature = NimbusFeatureConfigFactory.create()
|
||||
|
||||
versions = {
|
||||
(v.major, v.minor, v.patch): v
|
||||
for v in NimbusFeatureVersion.objects.bulk_create(
|
||||
NimbusFeatureVersion(
|
||||
major=major,
|
||||
minor=minor,
|
||||
patch=patch,
|
||||
)
|
||||
for major in range(1, 3)
|
||||
for minor in range(3)
|
||||
for patch in range(3)
|
||||
)
|
||||
}
|
||||
|
||||
schemas = {
|
||||
schema.version: schema
|
||||
for schema in NimbusVersionedSchema.objects.bulk_create(
|
||||
NimbusVersionedSchema(
|
||||
feature_config=feature,
|
||||
version=versions[(major, minor, patch)],
|
||||
sets_prefs=[],
|
||||
)
|
||||
for major in range(1, 3)
|
||||
for minor in range(3)
|
||||
for patch in range(3)
|
||||
)
|
||||
}
|
||||
|
||||
results = feature.schemas_between_versions(
|
||||
packaging.version.Version("1.2"),
|
||||
packaging.version.Version("2.1.1"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
set(results),
|
||||
{
|
||||
schemas[versions[v]]
|
||||
for v in (
|
||||
(1, 2, 0),
|
||||
(1, 2, 1),
|
||||
(1, 2, 2),
|
||||
(2, 0, 0),
|
||||
(2, 0, 1),
|
||||
(2, 0, 2),
|
||||
(2, 1, 0),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_versioned_schema_range_min_version_max_version_unsupported(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
version = NimbusFeatureVersion.objects.create(major=121, minor=0, patch=0)
|
||||
unversioned_schema = NimbusVersionedSchema.objects.get(
|
||||
feature_config=feature, version=None
|
||||
)
|
||||
NimbusVersionedSchemaFactory.create(feature_config=feature, version=version)
|
||||
|
||||
schemas_in_range = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("111.0.0"), packaging.version.Version("112.0.0")
|
||||
)
|
||||
self.assertEqual(
|
||||
schemas_in_range,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[unversioned_schema],
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=[],
|
||||
),
|
||||
)
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
None,
|
||||
packaging.version.Version("122.0.0"),
|
||||
]
|
||||
)
|
||||
def test_get_versioned_schema_range_min_version_unsupported(self, max_version):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
version = NimbusFeatureVersion.objects.create(major=121, minor=0, patch=0)
|
||||
versioned_schema = NimbusVersionedSchemaFactory.create(
|
||||
feature_config=feature, version=version
|
||||
)
|
||||
|
||||
info = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("111.0.0"), max_version
|
||||
)
|
||||
self.assertEqual(
|
||||
info,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[versioned_schema],
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=[],
|
||||
),
|
||||
)
|
||||
|
||||
def test_get_versioned_schema_range_unsupported_app(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DEMO_APP
|
||||
)
|
||||
unversioned_schema = feature.schemas.get(version=None)
|
||||
info = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("1.0.0"), None
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
info,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[unversioned_schema],
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=[],
|
||||
),
|
||||
)
|
||||
|
||||
def test_get_versioned_schema_range_unsupported_in_range(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
version = NimbusFeatureVersion.objects.create(major=123, minor=0, patch=0)
|
||||
NimbusVersionedSchemaFactory.create(feature_config=feature, version=version)
|
||||
schemas_in_range = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("121.0.0"), packaging.version.Version("122.0.0")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
schemas_in_range,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[],
|
||||
unsupported_in_range=True,
|
||||
unsupported_versions=[],
|
||||
),
|
||||
)
|
||||
|
||||
def test_get_versioned_schema_range_unsupported_versions(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
versions = {
|
||||
(v.major, v.minor, v.patch): v
|
||||
for v in NimbusFeatureVersion.objects.bulk_create(
|
||||
NimbusFeatureVersion(major=major, minor=minor, patch=0)
|
||||
for major in (121, 122, 123)
|
||||
for minor in (0, 1)
|
||||
)
|
||||
}
|
||||
# There needs to exist another feature for the same app with versioned
|
||||
# schemas so that we can infer the application supports those versions.
|
||||
feature_2 = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
NimbusVersionedSchema.objects.bulk_create(
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
feature_config=feature_2,
|
||||
version=version,
|
||||
)
|
||||
for version in versions.values()
|
||||
)
|
||||
|
||||
schema = NimbusVersionedSchemaFactory.create(
|
||||
feature_config=feature, version=versions[(122, 1, 0)]
|
||||
)
|
||||
schemas_in_range = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("121.0.0"), packaging.version.Version("124.0.0")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
schemas_in_range,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[schema],
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=[
|
||||
versions[v]
|
||||
for v in (
|
||||
(123, 1, 0),
|
||||
(123, 0, 0),
|
||||
(122, 0, 0),
|
||||
(121, 1, 0),
|
||||
(121, 0, 0),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
def test_get_versioned_schema_range(self):
|
||||
feature = NimbusFeatureConfigFactory.create(
|
||||
application=NimbusConstants.Application.DESKTOP
|
||||
)
|
||||
versions = {
|
||||
(v.major, v.minor, v.patch): v
|
||||
for v in NimbusFeatureVersion.objects.bulk_create(
|
||||
NimbusFeatureVersion(major=major, minor=minor, patch=0)
|
||||
for major in (121, 122, 123)
|
||||
for minor in (0, 1)
|
||||
)
|
||||
}
|
||||
schemas = {
|
||||
schema.version: schema
|
||||
for schema in NimbusVersionedSchema.objects.bulk_create(
|
||||
NimbusVersionedSchemaFactory.build(
|
||||
feature_config=feature,
|
||||
version=version,
|
||||
)
|
||||
for version in versions.values()
|
||||
)
|
||||
}
|
||||
|
||||
schemas_in_range = feature.get_versioned_schema_range(
|
||||
packaging.version.Version("122.0.0"), packaging.version.Version("123.1.0")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
schemas_in_range,
|
||||
NimbusFeatureConfig.VersionedSchemaRange(
|
||||
schemas=[
|
||||
schemas[versions[v]]
|
||||
for v in (
|
||||
(123, 0, 0),
|
||||
(122, 1, 0),
|
||||
(122, 0, 0),
|
||||
)
|
||||
],
|
||||
unsupported_in_range=False,
|
||||
unsupported_versions=[],
|
||||
),
|
||||
)
|
||||
|
|
Загрузка…
Ссылка в новой задаче