1
0
mirror of https://github.com/google/nomulus synced 2026-01-08 23:23:32 +00:00

Add nomulus tool command for FeatureFlags (#2480)

* Add registryTool commands for FeatureFlags

* Fix merge conflicts

* Add required parameters and inject mapper

* Use optionals in cache to negative cahe missing objects

* Fix spelling

* Change back to bulk load in cache

* Add FeatureName enum

* Change variable name

* Use FeatureName in main parameter
This commit is contained in:
sarahcaseybot
2024-07-09 16:05:15 -04:00
committed by GitHub
parent 092e3dca47
commit 74f0a8dd7b
16 changed files with 750 additions and 111 deletions

View File

@@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature;
import com.google.common.collect.ImmutableSortedSet;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.tld.Tld.TldState;
@@ -363,6 +364,33 @@ public class EntityYamlUtils {
}
}
/** A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link FeatureStatus}. */
public static class TimedTransitionPropertyFeatureStatusDeserializer
extends StdDeserializer<TimedTransitionProperty<FeatureStatus>> {
public TimedTransitionPropertyFeatureStatusDeserializer() {
this(null);
}
public TimedTransitionPropertyFeatureStatusDeserializer(
Class<TimedTransitionProperty<FeatureStatus>> t) {
super(t);
}
@Override
public TimedTransitionProperty<FeatureStatus> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
toImmutableSortedMap(
natural(),
DateTime::parse,
key -> FeatureStatus.valueOf(valueMap.get(key)))));
}
}
/** A custom JSON deserializer for a {@link CreateAutoTimestamp}. */
public static class CreateAutoTimestampDeserializer extends StdDeserializer<CreateAutoTimestamp> {

View File

@@ -14,29 +14,34 @@
package google.registry.model.common;
import static com.google.api.client.util.Preconditions.checkState;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.config.RegistryConfig.getSingletonCacheRefreshDuration;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Sets;
import com.google.common.collect.Maps;
import google.registry.model.Buildable;
import google.registry.model.CacheUtils;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyFeatureStatusDeserializer;
import google.registry.model.ImmutableObject;
import google.registry.persistence.VKey;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.Id;
import org.joda.time.DateTime;
@@ -54,60 +59,76 @@ public class FeatureFlag extends ImmutableObject implements Buildable {
INACTIVE
}
public enum FeatureName {
TEST_FEATURE,
MINIMUM_DATASET_CONTACTS_OPTIONAL,
MINIMUM_DATASET_CONTACTS_PROHIBITED
}
/** The name of the flag/feature. */
@Id String featureName;
@Enumerated(EnumType.STRING)
@Id
FeatureName featureName;
/** A map of times for each {@link FeatureStatus} the FeatureFlag should hold. */
@Column(nullable = false)
@JsonDeserialize(using = TimedTransitionPropertyFeatureStatusDeserializer.class)
TimedTransitionProperty<FeatureStatus> status =
TimedTransitionProperty.withInitialValue(FeatureStatus.INACTIVE);
public static FeatureFlag get(String featureName) {
FeatureFlag maybeFeatureFlag = CACHE.get(featureName);
if (maybeFeatureFlag == null) {
throw new FeatureFlagNotFoundException(featureName);
} else {
return maybeFeatureFlag;
}
public static Optional<FeatureFlag> getUncached(FeatureName featureName) {
return tm().transact(() -> tm().loadByKeyIfPresent(createVKey(featureName)));
}
public static ImmutableSet<FeatureFlag> get(Set<String> featureNames) {
Map<String, FeatureFlag> featureFlags = CACHE.getAll(featureNames);
ImmutableSet<String> missingFlags =
Sets.difference(featureNames, featureFlags.keySet()).immutableCopy();
public static ImmutableList<FeatureFlag> getAllUncached() {
return tm().transact(() -> tm().loadAllOf(FeatureFlag.class));
}
public static FeatureFlag get(FeatureName featureName) {
Optional<FeatureFlag> maybeFeatureFlag = CACHE.get(featureName);
return maybeFeatureFlag.orElseThrow(() -> new FeatureFlagNotFoundException(featureName));
}
public static ImmutableSet<FeatureFlag> getAll(Set<FeatureName> featureNames) {
Map<FeatureName, Optional<FeatureFlag>> featureFlags = CACHE.getAll(featureNames);
ImmutableSet<FeatureName> missingFlags =
featureFlags.entrySet().stream()
.filter(e -> e.getValue().isEmpty())
.map(Map.Entry::getKey)
.collect(toImmutableSet());
if (missingFlags.isEmpty()) {
return featureFlags.values().stream().collect(toImmutableSet());
return featureFlags.values().stream().map(Optional::get).collect(toImmutableSet());
} else {
throw new FeatureFlagNotFoundException(missingFlags);
}
}
/** A cache that loads the {@link FeatureFlag} for a given featureName. */
private static final LoadingCache<String, FeatureFlag> CACHE =
private static final LoadingCache<FeatureName, Optional<FeatureFlag>> CACHE =
CacheUtils.newCacheBuilder(getSingletonCacheRefreshDuration())
.build(
new CacheLoader<>() {
@Override
public FeatureFlag load(final String featureName) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(createVKey(featureName)))
.orElse(null);
public Optional<FeatureFlag> load(final FeatureName featureName) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(createVKey(featureName)));
}
@Override
public Map<? extends String, ? extends FeatureFlag> loadAll(
Set<? extends String> featureFlagNames) {
ImmutableMap<String, VKey<FeatureFlag>> keysMap =
public Map<? extends FeatureName, ? extends Optional<FeatureFlag>> loadAll(
Set<? extends FeatureName> featureFlagNames) {
ImmutableMap<FeatureName, VKey<FeatureFlag>> keysMap =
featureFlagNames.stream()
.collect(
toImmutableMap(featureName -> featureName, FeatureFlag::createVKey));
Map<VKey<? extends FeatureFlag>, FeatureFlag> entities =
tm().reTransact(() -> tm().loadByKeysIfPresent(keysMap.values()));
return entities.values().stream()
.collect(toImmutableMap(flag -> flag.featureName, flag -> flag));
return Maps.toMap(
featureFlagNames,
name -> Optional.ofNullable(entities.get(createVKey(name))));
}
});
public static VKey<FeatureFlag> createVKey(String featureName) {
public static VKey<FeatureFlag> createVKey(FeatureName featureName) {
return VKey.create(FeatureFlag.class, featureName);
}
@@ -116,10 +137,11 @@ public class FeatureFlag extends ImmutableObject implements Buildable {
return createVKey(featureName);
}
public String getFeatureName() {
public FeatureName getFeatureName() {
return featureName;
}
@JsonProperty("status")
public TimedTransitionProperty<FeatureStatus> getStatusMap() {
return status;
}
@@ -144,20 +166,17 @@ public class FeatureFlag extends ImmutableObject implements Buildable {
@Override
public FeatureFlag build() {
checkArgument(
!Strings.isNullOrEmpty(getInstance().featureName),
"Feature name must not be null or empty");
getInstance().status.checkValidity();
checkArgument(getInstance().featureName != null, "FeatureName cannot be null");
return super.build();
}
public Builder setFeatureName(String featureName) {
checkState(getInstance().featureName == null, "Feature name can only be set once");
public Builder setFeatureName(FeatureName featureName) {
getInstance().featureName = featureName;
return this;
}
public Builder setStatus(ImmutableSortedMap<DateTime, FeatureStatus> statusMap) {
public Builder setStatusMap(ImmutableSortedMap<DateTime, FeatureStatus> statusMap) {
getInstance().status = TimedTransitionProperty.fromValueMap(statusMap);
return this;
}
@@ -166,11 +185,11 @@ public class FeatureFlag extends ImmutableObject implements Buildable {
/** Exception to throw when no FeatureFlag entity is found for given FeatureName string(s). */
public static class FeatureFlagNotFoundException extends RuntimeException {
FeatureFlagNotFoundException(ImmutableSet<String> featureNames) {
FeatureFlagNotFoundException(ImmutableSet<FeatureName> featureNames) {
super("No feature flag object(s) found for " + Joiner.on(", ").join(featureNames));
}
FeatureFlagNotFoundException(String featureName) {
public FeatureFlagNotFoundException(FeatureName featureName) {
this(ImmutableSet.of(featureName));
}
}

View File

@@ -41,9 +41,9 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Range;
import com.google.common.collect.Sets;
import com.google.common.net.InternetDomainName;
import google.registry.model.Buildable;
import google.registry.model.CacheUtils;
@@ -192,21 +192,19 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
/** Returns the TLD for a given TLD, throwing if none exists. */
public static Tld get(String tld) {
Tld maybeTld = CACHE.get(tld);
if (maybeTld == null) {
throw new TldNotFoundException(tld);
} else {
return maybeTld;
}
return CACHE.get(tld).orElseThrow(() -> new TldNotFoundException(tld));
}
/** Returns the TLD entities for the given TLD strings, throwing if any don't exist. */
public static ImmutableSet<Tld> get(Set<String> tlds) {
Map<String, Tld> registries = CACHE.getAll(tlds);
Map<String, Optional<Tld>> registries = CACHE.getAll(tlds);
ImmutableSet<String> missingRegistries =
Sets.difference(tlds, registries.keySet()).immutableCopy();
registries.entrySet().stream()
.filter(e -> e.getValue().isEmpty())
.map(Map.Entry::getKey)
.collect(toImmutableSet());
if (missingRegistries.isEmpty()) {
return registries.values().stream().collect(toImmutableSet());
return registries.values().stream().map(Optional::get).collect(toImmutableSet());
} else {
throw new TldNotFoundException(missingRegistries);
}
@@ -224,24 +222,24 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
}
/** A cache that loads the {@link Tld} for a given tld. */
private static final LoadingCache<String, Tld> CACHE =
private static final LoadingCache<String, Optional<Tld>> CACHE =
CacheUtils.newCacheBuilder(getSingletonCacheRefreshDuration())
.build(
new CacheLoader<>() {
@Override
public Tld load(final String tld) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(createVKey(tld)))
.orElse(null);
public Optional<Tld> load(final String tld) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(createVKey(tld)));
}
@Override
public Map<? extends String, ? extends Tld> loadAll(Set<? extends String> tlds) {
public Map<? extends String, ? extends Optional<Tld>> loadAll(
Set<? extends String> tlds) {
ImmutableMap<String, VKey<Tld>> keysMap =
tlds.stream().collect(toImmutableMap(tld -> tld, Tld::createVKey));
Map<VKey<? extends Tld>, Tld> entities =
tm().reTransact(() -> tm().loadByKeysIfPresent(keysMap.values()));
return entities.values().stream()
.collect(toImmutableMap(tld -> tld.tldStr, tld -> tld));
return Maps.toMap(
tlds, tld -> Optional.ofNullable(entities.get(createVKey(tld))));
}
});

View File

@@ -0,0 +1,55 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters;
import com.google.common.collect.ImmutableSortedMap;
import google.registry.model.common.FeatureFlag;
import google.registry.model.common.FeatureFlag.FeatureName;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.tools.params.TransitionListParameter.FeatureStatusTransitions;
import java.util.List;
import java.util.Optional;
import org.joda.time.DateTime;
/** Command for creating and updating {@link FeatureFlag} objects. */
@Parameters(separators = " =", commandDescription = "Create or update a feature flag.")
public class ConfigureFeatureFlagCommand extends MutatingCommand {
@Parameter(description = "Feature flag name(s) to create or update", required = true)
private List<FeatureName> mainParameters;
@Parameter(
names = "--status_map",
converter = FeatureStatusTransitions.class,
validateWith = FeatureStatusTransitions.class,
description =
"Comma-delimited list of feature status transitions effective on specific dates, of the"
+ " form <time>=<status>[,<time>=<status>]* where each status represents the status"
+ " of the feature flag.",
required = true)
private ImmutableSortedMap<DateTime, FeatureStatus> featureStatusTransitions;
@Override
protected void init() throws Exception {
for (FeatureName name : mainParameters) {
Optional<FeatureFlag> oldFlag = FeatureFlag.getUncached(name);
FeatureFlag.Builder newFlagBuilder =
new FeatureFlag().asBuilder().setFeatureName(name).setStatusMap(featureStatusTransitions);
stageEntityChange(oldFlag.orElse(null), newFlagBuilder.build());
}
}
}

View File

@@ -111,8 +111,8 @@ abstract class CreateOrUpdateBulkPricingPackageCommand extends MutatingCommand {
if (clearLastNotificationSent()) {
builder.setLastNotificationSent(null);
}
BulkPricingPackage newBUlkPricingPackage = builder.build();
stageEntityChange(oldBulkPricingPackage, newBUlkPricingPackage);
BulkPricingPackage newBulkPricingPackage = builder.build();
stageEntityChange(oldBulkPricingPackage, newBulkPricingPackage);
});
}
}

View File

@@ -0,0 +1,53 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters;
import com.fasterxml.jackson.databind.ObjectMapper;
import google.registry.model.common.FeatureFlag;
import google.registry.model.common.FeatureFlag.FeatureFlagNotFoundException;
import google.registry.model.common.FeatureFlag.FeatureName;
import java.io.PrintStream;
import java.util.List;
import java.util.Optional;
import javax.inject.Inject;
/** Command to show a {@link FeatureFlag}. */
@Parameters(separators = " =", commandDescription = "Show FeatureFlag record(s)")
public class GetFeatureFlagCommand implements Command {
@Parameter(description = "Feature flag(s) to show", required = true)
private List<FeatureName> mainParameters;
@Inject ObjectMapper objectMapper;
@Override
public void run() throws Exception {
// Don't use try-with-resources to manage standard output streams, closing the stream will
// cause subsequent output to standard output or standard error to be lost
// See: https://errorprone.info/bugpattern/ClosingStandardOutputStreams
PrintStream printStream = new PrintStream(System.out, false, UTF_8);
for (FeatureName featureFlag : mainParameters) {
Optional<FeatureFlag> maybeFeatureFlag = FeatureFlag.getUncached(featureFlag);
if (maybeFeatureFlag.isEmpty()) {
throw new FeatureFlagNotFoundException(featureFlag);
}
printStream.println(objectMapper.writeValueAsString(maybeFeatureFlag.get()));
}
}
}

View File

@@ -0,0 +1,43 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.beust.jcommander.Parameters;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
import google.registry.model.common.FeatureFlag;
import java.io.PrintStream;
import javax.inject.Inject;
/** Command to list all {@link google.registry.model.common.FeatureFlag} objects. */
@Parameters(separators = " =", commandDescription = "List all feature flags.")
public class ListFeatureFlagsCommand implements Command {
@Inject ObjectMapper mapper;
@Override
public void run() throws Exception {
// Don't use try-with-resources to manage standard output streams, closing the stream will
// cause subsequent output to standard output or standard error to be lost
// See: https://errorprone.info/bugpattern/ClosingStandardOutputStreams
PrintStream printStream = new PrintStream(System.out, false, UTF_8);
ImmutableList<FeatureFlag> featureFlags = FeatureFlag.getAllUncached();
for (FeatureFlag featureFlag : featureFlags) {
printStream.println(mapper.writeValueAsString(featureFlag));
}
}
}

View File

@@ -33,6 +33,7 @@ public final class RegistryTool {
.put("canonicalize_labels", CanonicalizeLabelsCommand.class)
.put("check_domain", CheckDomainCommand.class)
.put("check_domain_claims", CheckDomainClaimsCommand.class)
.put("configure_feature_flag", ConfigureFeatureFlagCommand.class)
.put("configure_tld", ConfigureTldCommand.class)
.put("convert_idn", ConvertIdnCommand.class)
.put("count_domains", CountDomainsCommand.class)
@@ -71,6 +72,7 @@ public final class RegistryTool {
.put("get_claims_list", GetClaimsListCommand.class)
.put("get_contact", GetContactCommand.class)
.put("get_domain", GetDomainCommand.class)
.put("get_feature_flag", GetFeatureFlagCommand.class)
.put("get_history_entries", GetHistoryEntriesCommand.class)
.put("get_host", GetHostCommand.class)
.put("get_keyring_secret", GetKeyringSecretCommand.class)
@@ -85,6 +87,7 @@ public final class RegistryTool {
.put("hash_certificate", HashCertificateCommand.class)
.put("list_cursors", ListCursorsCommand.class)
.put("list_domains", ListDomainsCommand.class)
.put("list_feature_flags", ListFeatureFlagsCommand.class)
.put("list_hosts", ListHostsCommand.class)
.put("list_premium_lists", ListPremiumListsCommand.class)
.put("list_registrars", ListRegistrarsCommand.class)

View File

@@ -119,6 +119,8 @@ interface RegistryToolComponent {
void inject(GetDomainCommand command);
void inject(GetFeatureFlagCommand command);
void inject(GetHostCommand command);
void inject(GetKeyringSecretCommand command);
@@ -131,6 +133,8 @@ interface RegistryToolComponent {
void inject(ListCursorsCommand command);
void inject(ListFeatureFlagsCommand command);
void inject(LockDomainCommand command);
void inject(LoginCommand command);

View File

@@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Ordering;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.model.domain.token.AllocationToken.TokenStatus;
import google.registry.model.tld.Tld.TldState;
import org.joda.money.Money;
@@ -72,4 +73,12 @@ public abstract class TransitionListParameter<V> extends KeyValueMapParameter<Da
return TokenStatus.valueOf(value);
}
}
/** Converter-validator for feature status transitions. */
public static class FeatureStatusTransitions extends TransitionListParameter<FeatureStatus> {
@Override
protected FeatureStatus parseValue(String value) {
return FeatureStatus.valueOf(value);
}
}
}

View File

@@ -15,6 +15,9 @@
package google.registry.model.common;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
import static google.registry.model.common.FeatureFlag.FeatureName.TEST_FEATURE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE;
import static google.registry.testing.DatabaseHelper.loadByEntity;
@@ -42,8 +45,8 @@ public class FeatureFlagTest extends EntityTestCase {
void testSuccess_persistence() {
FeatureFlag featureFlag =
new FeatureFlag.Builder()
.setFeatureName("testFlag")
.setStatus(
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
@@ -58,15 +61,15 @@ public class FeatureFlagTest extends EntityTestCase {
void testSuccess_getSingleFlag() {
FeatureFlag featureFlag =
new FeatureFlag.Builder()
.setFeatureName("testFlag")
.setStatus(
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
.build())
.build();
persistResource(featureFlag);
assertThat(FeatureFlag.get("testFlag")).isEqualTo(featureFlag);
assertThat(FeatureFlag.get(TEST_FEATURE)).isEqualTo(featureFlag);
}
@Test
@@ -74,8 +77,8 @@ public class FeatureFlagTest extends EntityTestCase {
FeatureFlag featureFlag1 =
persistResource(
new FeatureFlag.Builder()
.setFeatureName("testFlag1")
.setStatus(
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
@@ -84,8 +87,8 @@ public class FeatureFlagTest extends EntityTestCase {
FeatureFlag featureFlag2 =
persistResource(
new FeatureFlag.Builder()
.setFeatureName("testFlag2")
.setStatus(
.setFeatureName(MINIMUM_DATASET_CONTACTS_OPTIONAL)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(3), INACTIVE)
@@ -94,14 +97,18 @@ public class FeatureFlagTest extends EntityTestCase {
FeatureFlag featureFlag3 =
persistResource(
new FeatureFlag.Builder()
.setFeatureName("testFlag3")
.setStatus(
.setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.build())
.build());
ImmutableSet<FeatureFlag> featureFlags =
FeatureFlag.get(ImmutableSet.of("testFlag1", "testFlag2", "testFlag3"));
FeatureFlag.getAll(
ImmutableSet.of(
TEST_FEATURE,
MINIMUM_DATASET_CONTACTS_OPTIONAL,
MINIMUM_DATASET_CONTACTS_PROHIBITED));
assertThat(featureFlags.size()).isEqualTo(3);
assertThat(featureFlags).containsExactly(featureFlag1, featureFlag2, featureFlag3);
}
@@ -110,8 +117,8 @@ public class FeatureFlagTest extends EntityTestCase {
void testFailure_getMultipleFlagsOneMissing() {
persistResource(
new FeatureFlag.Builder()
.setFeatureName("testFlag1")
.setStatus(
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
@@ -119,8 +126,8 @@ public class FeatureFlagTest extends EntityTestCase {
.build());
persistResource(
new FeatureFlag.Builder()
.setFeatureName("testFlag2")
.setStatus(
.setFeatureName(MINIMUM_DATASET_CONTACTS_OPTIONAL)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(3), INACTIVE)
@@ -129,74 +136,48 @@ public class FeatureFlagTest extends EntityTestCase {
FeatureFlagNotFoundException thrown =
assertThrows(
FeatureFlagNotFoundException.class,
() -> FeatureFlag.get(ImmutableSet.of("missingFlag", "testFlag1", "testFlag2")));
() ->
FeatureFlag.getAll(
ImmutableSet.of(
TEST_FEATURE,
MINIMUM_DATASET_CONTACTS_OPTIONAL,
MINIMUM_DATASET_CONTACTS_PROHIBITED)));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("No feature flag object(s) found for missingFlag");
.isEqualTo("No feature flag object(s) found for MINIMUM_DATASET_CONTACTS_PROHIBITED");
}
@Test
void testFailure_featureFlagNotPresent() {
FeatureFlagNotFoundException thrown =
assertThrows(FeatureFlagNotFoundException.class, () -> FeatureFlag.get("fakeFlag"));
assertThat(thrown).hasMessageThat().isEqualTo("No feature flag object(s) found for fakeFlag");
}
@Test
void testFailure_resetFeatureName() {
FeatureFlag featureFlag =
new FeatureFlag.Builder()
.setFeatureName("testFlag")
.setStatus(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
.build())
.build();
IllegalStateException thrown =
assertThrows(
IllegalStateException.class,
() -> featureFlag.asBuilder().setFeatureName("differentName"));
assertThat(thrown).hasMessageThat().isEqualTo("Feature name can only be set once");
assertThrows(FeatureFlagNotFoundException.class, () -> FeatureFlag.get(TEST_FEATURE));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("No feature flag object(s) found for TEST_FEATURE");
}
@Test
void testFailure_nullFeatureName() {
FeatureFlag.Builder featureFlagBuilder =
new FeatureFlag.Builder()
.setStatus(
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
.build());
IllegalArgumentException thrown =
assertThrows(IllegalArgumentException.class, () -> featureFlagBuilder.build());
assertThat(thrown).hasMessageThat().isEqualTo("Feature name must not be null or empty");
}
@Test
void testFailure_emptyFeatureName() {
FeatureFlag.Builder featureFlagBuilder =
new FeatureFlag.Builder()
.setFeatureName("")
.setStatus(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
.build());
IllegalArgumentException thrown =
assertThrows(IllegalArgumentException.class, () -> featureFlagBuilder.build());
assertThat(thrown).hasMessageThat().isEqualTo("Feature name must not be null or empty");
assertThat(thrown).hasMessageThat().isEqualTo("FeatureName cannot be null");
}
@Test
void testFailure_invalidStatusMap() {
FeatureFlag.Builder featureFlagBuilder = new FeatureFlag.Builder().setFeatureName("testFlag");
FeatureFlag.Builder featureFlagBuilder = new FeatureFlag.Builder().setFeatureName(TEST_FEATURE);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
featureFlagBuilder.setStatus(
featureFlagBuilder.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(DateTime.now(UTC).plusWeeks(8), ACTIVE)
.build()));

View File

@@ -0,0 +1,208 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
import static google.registry.model.common.FeatureFlag.FeatureName.TEST_FEATURE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.util.DateTimeUtils.START_OF_TIME;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.beust.jcommander.ParameterException;
import com.google.common.collect.ImmutableSortedMap;
import google.registry.model.common.FeatureFlag;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.testing.FakeClock;
import org.joda.time.DateTime;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link ConfigureFeatureFlagCommand}. */
public class ConfigureFeatureFlagCommandTest extends CommandTestCase<ConfigureFeatureFlagCommand> {
private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01T00:00:00Z"));
@Test
void testCreate() throws Exception {
DateTime featureStart = clock.nowUtc().plusWeeks(2);
runCommandForced(
"TEST_FEATURE",
"--status_map",
String.format("%s=INACTIVE,%s=ACTIVE", START_OF_TIME, featureStart));
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(1);
}
@Test
void testCreate_multipleFlags() throws Exception {
DateTime featureStart = clock.nowUtc().plusWeeks(2);
runCommandForced(
"TEST_FEATURE",
"MINIMUM_DATASET_CONTACTS_OPTIONAL",
"MINIMUM_DATASET_CONTACTS_PROHIBITED",
"--status_map",
String.format("%s=INACTIVE,%s=ACTIVE", START_OF_TIME, featureStart));
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.get(MINIMUM_DATASET_CONTACTS_OPTIONAL).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.get(MINIMUM_DATASET_CONTACTS_PROHIBITED).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(3);
}
@Test
void testUpdate() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.build())
.build());
DateTime featureStart = clock.nowUtc().plusWeeks(6);
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(ImmutableSortedMap.of(START_OF_TIME, INACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(1);
runCommandForced(
"TEST_FEATURE",
"--status_map",
String.format("%s=INACTIVE,%s=ACTIVE", START_OF_TIME, featureStart));
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(1);
}
@Test
void testConfigure_multipleFlags() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.build())
.build());
DateTime featureStart = clock.nowUtc().plusWeeks(6);
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(ImmutableSortedMap.of(START_OF_TIME, INACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(1);
runCommandForced(
"TEST_FEATURE",
"MINIMUM_DATASET_CONTACTS_OPTIONAL",
"MINIMUM_DATASET_CONTACTS_PROHIBITED",
"--status_map",
String.format("%s=INACTIVE,%s=ACTIVE", START_OF_TIME, featureStart));
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.get(MINIMUM_DATASET_CONTACTS_OPTIONAL).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.get(MINIMUM_DATASET_CONTACTS_PROHIBITED).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, featureStart, ACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(3);
}
@Test
void testCreate_invalidFeatureName() throws Exception {
ParameterException thrown =
assertThrows(
ParameterException.class,
() ->
runCommandForced(
"INVALID_NAME", "--status_map", String.format("%s=ACTIVE", START_OF_TIME)));
assertThat(thrown)
.hasMessageThat()
.contains("Invalid value for [Main class] parameter. Allowed values");
}
@Test
void testCreate_invalidStatusMap() throws Exception {
DateTime featureStart = clock.nowUtc().plusWeeks(2);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
runCommandForced(
"TEST_FEATURE", "--status_map", String.format("%s=ACTIVE", featureStart)));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("Must provide transition entry for the start of time (Unix Epoch)");
}
@Test
void testUpdate_invalidStatusMap() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.build())
.build());
DateTime featureStart = clock.nowUtc().plusWeeks(6);
assertThat(FeatureFlag.get(TEST_FEATURE).getStatusMap())
.isEqualTo(
TimedTransitionProperty.fromValueMap(ImmutableSortedMap.of(START_OF_TIME, INACTIVE)));
assertThat(FeatureFlag.getAllUncached()).hasSize(1);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
runCommandForced(
"TEST_FEATURE", "--status_map", String.format("%s=ACTIVE", featureStart)));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("Must provide transition entry for the start of time (Unix Epoch)");
}
}

View File

@@ -0,0 +1,130 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL;
import static google.registry.model.common.FeatureFlag.FeatureName.TEST_FEATURE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.util.DateTimeUtils.START_OF_TIME;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.beust.jcommander.ParameterException;
import com.google.common.collect.ImmutableSortedMap;
import google.registry.model.EntityYamlUtils;
import google.registry.model.common.FeatureFlag;
import google.registry.model.common.FeatureFlag.FeatureFlagNotFoundException;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.testing.FakeClock;
import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link GetFeatureFlagCommand}. */
public class GetFeatureFlagCommandTest extends CommandTestCase<GetFeatureFlagCommand> {
private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01T00:00:00Z"));
@BeforeEach
void beforeEach() {
command.objectMapper = EntityYamlUtils.createObjectMapper();
}
@Test
void testSuccess() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(clock.nowUtc().plusWeeks(8), ACTIVE)
.build())
.build());
runCommand("TEST_FEATURE");
assertInStdout(
"featureName: \"TEST_FEATURE\"\n"
+ "status:\n"
+ " \"1970-01-01T00:00:00.000Z\": \"INACTIVE\"\n"
+ " \"2000-02-26T00:00:00.000Z\": \"ACTIVE\"");
}
@Test
void testSuccess_multipleArguments() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(clock.nowUtc().plusWeeks(8), ACTIVE)
.build())
.build());
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_OPTIONAL)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(clock.nowUtc().plusWeeks(3), ACTIVE)
.put(clock.nowUtc().plusWeeks(6), INACTIVE)
.build())
.build());
runCommand("TEST_FEATURE", "MINIMUM_DATASET_CONTACTS_OPTIONAL");
assertInStdout(
"featureName: \"TEST_FEATURE\"\n"
+ "status:\n"
+ " \"1970-01-01T00:00:00.000Z\": \"INACTIVE\"\n"
+ " \"2000-02-26T00:00:00.000Z\": \"ACTIVE\""
+ "\n\n"
+ "featureName: \"MINIMUM_DATASET_CONTACTS_OPTIONAL\"\n"
+ "status:\n"
+ " \"1970-01-01T00:00:00.000Z\": \"INACTIVE\"\n"
+ " \"2000-01-22T00:00:00.000Z\": \"ACTIVE\"\n"
+ " \"2000-02-12T00:00:00.000Z\": \"INACTIVE\"");
}
@Test
void testFailure_featureFlagDoesNotExist() {
FeatureFlagNotFoundException thrown =
assertThrows(FeatureFlagNotFoundException.class, () -> runCommand("TEST_FEATURE"));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("No feature flag object(s) found for TEST_FEATURE");
}
@Test
void testFailure_oneFlagDoesNotExist() {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(clock.nowUtc().plusWeeks(8), ACTIVE)
.build())
.build());
assertThrows(
FeatureFlagNotFoundException.class,
() -> runCommand("TEST_FEATURE", "MINIMUM_DATASET_CONTACTS_OPTIONAL"));
}
@Test
void testFailure_noTldName() {
assertThrows(ParameterException.class, this::runCommand);
}
}

View File

@@ -0,0 +1,89 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.tools;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
import static google.registry.model.common.FeatureFlag.FeatureName.TEST_FEATURE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.TestDataHelper.loadFile;
import static google.registry.util.DateTimeUtils.START_OF_TIME;
import com.google.common.collect.ImmutableSortedMap;
import google.registry.model.EntityYamlUtils;
import google.registry.model.common.FeatureFlag;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class ListFeatureFlagsCommandTest extends CommandTestCase<ListFeatureFlagsCommand> {
@BeforeEach
void beforeEach() {
command.mapper = EntityYamlUtils.createObjectMapper();
fakeClock.setTo(DateTime.parse("1984-12-21T06:07:08.789Z"));
}
@Test
void testSuccess_oneFlag() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(fakeClock.nowUtc().plusWeeks(8), ACTIVE)
.build())
.build());
runCommand();
assertInStdout(loadFile(getClass(), "oneFlag.yaml"));
}
@Test
void test_success_manyFlags() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(TEST_FEATURE)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(fakeClock.nowUtc().plusWeeks(8), ACTIVE)
.build())
.build());
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_OPTIONAL)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, INACTIVE)
.put(fakeClock.nowUtc().plusWeeks(1), ACTIVE)
.put(fakeClock.nowUtc().plusWeeks(8), INACTIVE)
.put(fakeClock.nowUtc().plusWeeks(10), ACTIVE)
.build())
.build());
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
.setStatusMap(
ImmutableSortedMap.<DateTime, FeatureStatus>naturalOrder()
.put(START_OF_TIME, ACTIVE)
.build())
.build());
runCommand();
assertInStdout(loadFile(getClass(), "threeFlags.yaml"));
}
}

View File

@@ -0,0 +1,4 @@
featureName: "TEST_FEATURE"
status:
"1970-01-01T00:00:00.000Z": "INACTIVE"
"1985-02-15T06:07:08.789Z": "ACTIVE"

View File

@@ -0,0 +1,15 @@
featureName: "TEST_FEATURE"
status:
"1970-01-01T00:00:00.000Z": "INACTIVE"
"1985-02-15T06:07:08.789Z": "ACTIVE"
featureName: "MINIMUM_DATASET_CONTACTS_OPTIONAL"
status:
"1970-01-01T00:00:00.000Z": "INACTIVE"
"1984-12-28T06:07:08.789Z": "ACTIVE"
"1985-02-15T06:07:08.789Z": "INACTIVE"
"1985-03-01T06:07:08.789Z": "ACTIVE"
featureName: "MINIMUM_DATASET_CONTACTS_PROHIBITED"
status:
"1970-01-01T00:00:00.000Z": "ACTIVE"