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:
Barret Rennie 2023-12-01 16:43:11 -05:00 коммит произвёл GitHub
Родитель 80fa3a3164
Коммит 835e1d26ba
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
6 изменённых файлов: 967 добавлений и 27 удалений

Просмотреть файл

@ -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=[],
),
)