Repository: firebase/firebase-admin-java
Pull request #491 (2020-11-03 19:45:14)

Add Version information to RC template
- Add Version information to the Template
- Create new Version type
- Create new User type
- ~Note: Since Version and User types are output only, I did not implement `equal()` and `hashcode()` operations. Instead, unit tests are checking for each property in `Version` and `User` individually. I also removed the equality operations from `Template`, as well.~
- Based on PR review feedback, implement equality in all public types (including `Version` and `User`) to keep consistency.

Related to: #446 

lahirumaramba: Implemented `equal()` in all public types (including `Version` and `User`) to keep consistency

Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/Template.java b/src/main/java/com/google/firebase/remoteconfig/Template.java
index f54cc80..c6ffcb4 100644
--- a/src/main/java/com/google/firebase/remoteconfig/Template.java
+++ b/src/main/java/com/google/firebase/remoteconfig/Template.java
@@ -36,6 +36,7 @@ public final class Template {
   private Map<String, Parameter> parameters;
   private List<Condition> conditions;
   private Map<String, ParameterGroup> parameterGroups;
+  private Version version;
 
   /**
    * Creates a new {@link Template}.
@@ -69,6 +70,9 @@ public final class Template {
         this.parameterGroups.put(entry.getKey(), new ParameterGroup(entry.getValue()));
       }
     }
+    if (templateResponse.getVersion() != null) {
+      this.version = new Version(templateResponse.getVersion());
+    }
   }
 
   /**
@@ -111,6 +115,15 @@ public final class Template {
     return parameterGroups;
   }
 
+  /**
+   * Gets the version information of the template.
+   *
+   * @return The version information of the template.
+   */
+  public Version getVersion() {
+    return version;
+  }
+
   /**
    * Sets the map of parameters of the template.
    *
@@ -152,6 +165,18 @@ public final class Template {
     return this;
   }
 
+  /**
+   * Sets the version information of the template.
+   * Only the version's description field can be specified here.
+   *
+   * @param version A {@link Version} instance.
+   * @return This {@link Template} instance.
+   */
+  public Template setVersion(Version version) {
+    this.version = version;
+    return this;
+  }
+
   Template setETag(String etag) {
     this.etag = etag;
     return this;
@@ -170,10 +195,13 @@ public final class Template {
     for (Map.Entry<String, ParameterGroup> entry : this.parameterGroups.entrySet()) {
       parameterGroupResponse.put(entry.getKey(), entry.getValue().toParameterGroupResponse());
     }
+    TemplateResponse.VersionResponse versionResponse = (this.version == null) ? null
+            : this.version.toVersionResponse();
     return new TemplateResponse()
             .setParameters(parameterResponses)
             .setConditions(conditionResponses)
-            .setParameterGroups(parameterGroupResponse);
+            .setParameterGroups(parameterGroupResponse)
+            .setVersion(versionResponse);
   }
 
   @Override
@@ -188,11 +216,12 @@ public final class Template {
     return Objects.equals(etag, template.etag)
             && Objects.equals(parameters, template.parameters)
             && Objects.equals(conditions, template.conditions)
-            && Objects.equals(parameterGroups, template.parameterGroups);
+            && Objects.equals(parameterGroups, template.parameterGroups)
+            && Objects.equals(version, template.version);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(etag, parameters, conditions, parameterGroups);
+    return Objects.hash(etag, parameters, conditions, parameterGroups, version);
   }
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/User.java b/src/main/java/com/google/firebase/remoteconfig/User.java
new file mode 100644
index 0000000..ae21328
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/User.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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 com.google.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.remoteconfig.internal.TemplateResponse.UserResponse;
+
+import java.util.Objects;
+
+/**
+ * Represents a Remote Config user. Output only.
+ */
+public class User {
+
+  private final String email;
+  private final String name;
+  private final String imageUrl;
+
+  User(@NonNull UserResponse userResponse) {
+    checkNotNull(userResponse);
+    this.email = userResponse.getEmail();
+    this.name = userResponse.getName();
+    this.imageUrl = userResponse.getImageUrl();
+  }
+
+  /**
+   * Gets the email of the user.
+   *
+   * @return The email of the user or null.
+   */
+  @Nullable
+  public String getEmail() {
+    return email;
+  }
+
+  /**
+   * Gets the name of the user.
+   *
+   * @return The name of the user or null.
+   */
+  @Nullable
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * Gets the image URL of the user.
+   *
+   * @return The image URL of the user or null.
+   */
+  @Nullable
+  public String getImageUrl() {
+    return imageUrl;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    User user = (User) o;
+    return Objects.equals(email, user.email)
+            && Objects.equals(name, user.name)
+            && Objects.equals(imageUrl, user.imageUrl);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(email, name, imageUrl);
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/Version.java b/src/main/java/com/google/firebase/remoteconfig/Version.java
new file mode 100644
index 0000000..e3c1824
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/Version.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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 com.google.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+import com.google.firebase.remoteconfig.internal.TemplateResponse.VersionResponse;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Objects;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Represents a Remote Config template version.
+ * Output only, except for the version description. Contains metadata about a particular
+ * version of the Remote Config template. All fields are set at the time the specified Remote
+ * Config template is published. A version's description field may be specified when
+ * publishing a template.
+ */
+public final class Version {
+
+  private final String versionNumber;
+  private final long updateTime;
+  private final String updateOrigin;
+  private final String updateType;
+  private final User updateUser;
+  private final String rollbackSource;
+  private final boolean legacy;
+  private String description;
+
+  private Version() {
+    this.versionNumber = null;
+    this.updateTime = 0L;
+    this.updateOrigin = null;
+    this.updateType = null;
+    this.updateUser = null;
+    this.rollbackSource = null;
+    this.legacy = false;
+  }
+
+  Version(@NonNull VersionResponse versionResponse) {
+    checkNotNull(versionResponse);
+    this.versionNumber = versionResponse.getVersionNumber();
+
+    if (!Strings.isNullOrEmpty(versionResponse.getUpdateTime())) {
+      // Update Time is a timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds.
+      // example: "2014-10-02T15:01:23.045123456Z"
+      // SimpleDateFormat cannot handle nanoseconds, therefore we strip nanoseconds from the string.
+      String updateTime = versionResponse.getUpdateTime();
+      int indexOfPeriod = updateTime.indexOf(".");
+      if (indexOfPeriod != -1) {
+        updateTime = updateTime.substring(0, indexOfPeriod);
+      }
+      SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
+      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+      try {
+        this.updateTime = dateFormat.parse(updateTime).getTime();
+      } catch (ParseException e) {
+        throw new IllegalStateException("Unable to parse update time.", e);
+      }
+    } else {
+      this.updateTime = 0L;
+    }
+
+    this.updateOrigin = versionResponse.getUpdateOrigin();
+    this.updateType = versionResponse.getUpdateType();
+    TemplateResponse.UserResponse userResponse = versionResponse.getUpdateUser();
+    this.updateUser = (userResponse != null) ? new User(userResponse) : null;
+    this.description = versionResponse.getDescription();
+    this.rollbackSource = versionResponse.getRollbackSource();
+    this.legacy = versionResponse.isLegacy();
+  }
+
+  /**
+   * Creates a new {@link Version} with a description.
+   */
+  public static Version withDescription(String description) {
+    return new Version().setDescription(description);
+  }
+
+  /**
+   * Gets the version number of the template.
+   *
+   * @return The version number or null.
+   */
+  @Nullable
+  public String getVersionNumber() {
+    return versionNumber;
+  }
+
+  /**
+   * Gets the update time of the version. The timestamp of when this version of the Remote Config
+   * template was written to the Remote Config backend.
+   *
+   * @return The update time of the version or null.
+   */
+  @Nullable
+  public long getUpdateTime() {
+    return updateTime;
+  }
+
+  /**
+   * Gets the origin of the template update action.
+   *
+   * @return The origin of the template update action or null.
+   */
+  @Nullable
+  public String getUpdateOrigin() {
+    return updateOrigin;
+  }
+
+  /**
+   * Gets the type of the template update action.
+   *
+   * @return The type of the template update action or null.
+   */
+  @Nullable
+  public String getUpdateType() {
+    return updateType;
+  }
+
+  /**
+   * Gets the update user of the template.
+   * An aggregation of all metadata fields about the account that performed the update.
+   *
+   * @return The update user of the template or null.
+   */
+  @Nullable
+  public User getUpdateUser() {
+    return updateUser;
+  }
+
+  /**
+   * Gets the user-provided description of the corresponding Remote Config template.
+   *
+   * @return The description of the template or null.
+   */
+  @Nullable
+  public String getDescription() {
+    return description;
+  }
+
+  /**
+   * Gets the rollback source of the template.
+   *
+   * <p>The version number of the Remote Config template that has become the current version
+   * due to a rollback. Only present if this version is the result of a rollback.
+   *
+   * @return The rollback source of the template or null.
+   */
+  @Nullable
+  public String getRollbackSource() {
+    return rollbackSource;
+  }
+
+  /**
+   * Indicates whether this Remote Config template was published before version history was
+   * supported.
+   *
+   * @return true if the template was published before version history was supported,
+   *     and false otherwise.
+   */
+  public boolean isLegacy() {
+    return legacy;
+  }
+
+  /**
+   * Sets the user-provided description of the template.
+   *
+   * @param description The description of the template.
+   * @return This {@link Version}.
+   */
+  public Version setDescription(String description) {
+    this.description = description;
+    return this;
+  }
+
+  VersionResponse toVersionResponse() {
+    return new VersionResponse()
+            .setDescription(this.description);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    Version version = (Version) o;
+    return updateTime == version.updateTime
+            && legacy == version.legacy
+            && Objects.equals(versionNumber, version.versionNumber)
+            && Objects.equals(updateOrigin, version.updateOrigin)
+            && Objects.equals(updateType, version.updateType)
+            && Objects.equals(updateUser, version.updateUser)
+            && Objects.equals(description, version.description)
+            && Objects.equals(rollbackSource, version.rollbackSource);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects
+            .hash(versionNumber, updateTime, updateOrigin, updateType, updateUser, description,
+                    rollbackSource, legacy);
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
index 1995362..9a209a2 100644
--- a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
+++ b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
@@ -36,6 +36,9 @@ public final class TemplateResponse {
   @Key("parameterGroups")
   private Map<String, ParameterGroupResponse> parameterGroups;
 
+  @Key("version")
+  private VersionResponse version;
+
   public Map<String, ParameterResponse> getParameters() {
     return parameters;
   }
@@ -48,6 +51,10 @@ public final class TemplateResponse {
     return parameterGroups;
   }
 
+  public VersionResponse getVersion() {
+    return version;
+  }
+
   public TemplateResponse setParameters(
           Map<String, ParameterResponse> parameters) {
     this.parameters = parameters;
@@ -66,6 +73,11 @@ public final class TemplateResponse {
     return this;
   }
 
+  public TemplateResponse setVersion(VersionResponse version) {
+    this.version = version;
+    return this;
+  }
+
   /**
    * The Data Transfer Object for parsing Remote Config parameter responses from the
    * Remote Config service.
@@ -216,4 +228,148 @@ public final class TemplateResponse {
       return this;
     }
   }
+
+  /**
+   * The Data Transfer Object for parsing Remote Config version responses from the
+   * Remote Config service.
+   **/
+  public static final class VersionResponse {
+    @Key("versionNumber")
+    private String versionNumber;
+
+    @Key("updateTime")
+    private String updateTime;
+
+    @Key("updateOrigin")
+    private String updateOrigin;
+
+    @Key("updateType")
+    private String updateType;
+
+    @Key("updateUser")
+    private UserResponse updateUser;
+
+    @Key("description")
+    private String description;
+
+    @Key("rollbackSource")
+    private String rollbackSource;
+
+    @Key("legacy")
+    private Boolean legacy;
+
+    public String getVersionNumber() {
+      return versionNumber;
+    }
+
+    public String getUpdateTime() {
+      return updateTime;
+    }
+
+    public String getUpdateOrigin() {
+      return updateOrigin;
+    }
+
+    public String getUpdateType() {
+      return updateType;
+    }
+
+    public UserResponse getUpdateUser() {
+      return updateUser;
+    }
+
+    public String getDescription() {
+      return description;
+    }
+
+    public String getRollbackSource() {
+      return rollbackSource;
+    }
+
+    public boolean isLegacy() {
+      return Boolean.TRUE.equals(this.legacy);
+    }
+
+    public VersionResponse setDescription(String description) {
+      this.description = description;
+      return this;
+    }
+
+    public VersionResponse setVersionNumber(String versionNumber) {
+      this.versionNumber = versionNumber;
+      return this;
+    }
+
+    public VersionResponse setUpdateTime(String updateTime) {
+      this.updateTime = updateTime;
+      return this;
+    }
+
+    public VersionResponse setUpdateOrigin(String updateOrigin) {
+      this.updateOrigin = updateOrigin;
+      return this;
+    }
+
+    public VersionResponse setUpdateType(String updateType) {
+      this.updateType = updateType;
+      return this;
+    }
+
+    public VersionResponse setUpdateUser(UserResponse updateUser) {
+      this.updateUser = updateUser;
+      return this;
+    }
+
+    public VersionResponse setRollbackSource(String rollbackSource) {
+      this.rollbackSource = rollbackSource;
+      return this;
+    }
+
+    public VersionResponse setLegacy(Boolean legacy) {
+      this.legacy = legacy;
+      return this;
+    }
+  }
+
+  /**
+   * The Data Transfer Object for parsing Remote Config user responses from the
+   * Remote Config service.
+   **/
+  public static final class UserResponse {
+    @Key("email")
+    private String email;
+
+    @Key("name")
+    private String name;
+
+    @Key("imageUrl")
+    private String imageUrl;
+
+    public String getEmail() {
+      return email;
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public String getImageUrl() {
+      return imageUrl;
+    }
+
+    public UserResponse setEmail(String email) {
+      this.email = email;
+      return this;
+    }
+
+    public UserResponse setName(String name) {
+      this.name = name;
+      return this;
+    }
+
+    public UserResponse setImageUrl(String imageUrl) {
+      this.imageUrl = imageUrl;
+      return this;
+    }
+  }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
index 910cc04..4c1bac0 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -41,12 +41,16 @@ import com.google.firebase.FirebaseOptions;
 import com.google.firebase.OutgoingHttpRequest;
 import com.google.firebase.auth.MockGoogleCredentials;
 import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
 import com.google.firebase.testing.TestResponseInterceptor;
 import com.google.firebase.testing.TestUtils;
 
 import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.TimeZone;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -85,7 +89,7 @@ public class FirebaseRemoteConfigClientImplTest {
     response.setContent(MOCK_TEMPLATE_RESPONSE);
 
     Template receivedTemplate = client.getTemplate();
-    Map<String, Parameter> expectedParameters = ImmutableMap.of(
+    final Map<String, Parameter> expectedParameters = ImmutableMap.of(
             "welcome_message_text", new Parameter()
                     .setDefaultValue(ParameterValue.of("welcome to app"))
                     .setConditionalValues(ImmutableMap.<String, ParameterValue>of(
@@ -95,7 +99,7 @@ public class FirebaseRemoteConfigClientImplTest {
             "header_text", new Parameter()
                     .setDefaultValue(ParameterValue.inAppDefault())
     );
-    Map<String, ParameterGroup> expectedParameterGroups = ImmutableMap.of(
+    final Map<String, ParameterGroup> expectedParameterGroups = ImmutableMap.of(
             "new menu", new ParameterGroup()
                     .setDescription("New Menu")
                     .setParameters(ImmutableMap.of(
@@ -105,21 +109,35 @@ public class FirebaseRemoteConfigClientImplTest {
                             )
                     )
     );
-    List<Condition> expectedConditions = ImmutableList.of(
+    final List<Condition> expectedConditions = ImmutableList.of(
             new Condition("ios_en", "device.os == 'ios' && device.country in ['us', 'uk']")
                     .setTagColor(TagColor.INDIGO),
             new Condition("android_en",
                     "device.os == 'android' && device.country in ['us', 'uk']")
                     .setTagColor(TagColor.UNSPECIFIED)
     );
+    final Version expectedVersion = new Version(new TemplateResponse.VersionResponse()
+            .setVersionNumber("17")
+            .setUpdateOrigin("ADMIN_SDK_NODE")
+            .setUpdateType("INCREMENTAL_UPDATE")
+            .setUpdateUser(new TemplateResponse.UserResponse()
+                    .setEmail("firebase-user@account.com")
+                    .setName("dev-admin")
+                    .setImageUrl("http://image.jpg"))
+            .setUpdateTime("2020-11-03T20:24:15.045123456Z")
+            .setDescription("promo config")
+    );
+
     Template expectedTemplate = new Template()
+            .setETag(TEST_ETAG)
             .setParameters(expectedParameters)
-            .setParameterGroups(expectedParameterGroups)
             .setConditions(expectedConditions)
-            .setETag(TEST_ETAG);
+            .setParameterGroups(expectedParameterGroups)
+            .setVersion(expectedVersion);
 
     assertEquals(TEST_ETAG, receivedTemplate.getETag());
     assertEquals(expectedTemplate, receivedTemplate);
+    assertEquals(1604435055000L, receivedTemplate.getVersion().getUpdateTime());
     checkGetRequestHeader(interceptor.getLastRequest());
   }
 
diff --git a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
index ce27230..57f5a21 100644
--- a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
@@ -32,6 +32,52 @@ import org.junit.Test;
 
 public class TemplateTest {
 
+  private static final List<Condition> CONDITIONS = ImmutableList.<Condition>of(
+          new Condition("ios_en", "exp ios")
+                  .setTagColor(TagColor.INDIGO),
+          new Condition("android_en", "exp android")
+  );
+
+  private static final Map<String, ParameterValue> CONDITIONAL_VALUES = ImmutableMap.of(
+          "ios", ParameterValue.of("hello ios"),
+          "android", ParameterValue.of("hello android"),
+          "promo", ParameterValue.inAppDefault()
+  );
+
+  private static final Map<String, Parameter> PARAMETERS = ImmutableMap.of(
+          "greeting_header", new Parameter()
+                  .setDefaultValue(ParameterValue.inAppDefault())
+                  .setDescription("greeting header text")
+                  .setConditionalValues(CONDITIONAL_VALUES),
+          "greeting_text", new Parameter()
+                  .setDefaultValue(ParameterValue.inAppDefault())
+                  .setDescription("greeting text")
+                  .setConditionalValues(CONDITIONAL_VALUES)
+  );
+
+  private static final Map<String, ParameterGroup> PARAMETER_GROUPS = ImmutableMap.of(
+          "greetings_group", new ParameterGroup()
+                  .setDescription("description")
+                  .setParameters(PARAMETERS)
+  );
+
+  private static final Template EMPTY_TEMPLATE = new Template();
+
+  private static final Template TEMPLATE_WITH_CONDITIONS_PARAMETERS = new Template()
+          .setConditions(CONDITIONS)
+          .setParameters(PARAMETERS);
+
+  private static final Template TEMPLATE_WITH_CONDITIONS_PARAMETERS_GROUPS = new Template()
+          .setConditions(CONDITIONS)
+          .setParameters(PARAMETERS)
+          .setParameterGroups(PARAMETER_GROUPS);
+
+  private static final Template TEMPLATE_WITH_ETAG = new Template()
+          .setETag("etag-123456789097-20");
+
+  private static final Template TEMPLATE_WITH_VERSION = new Template()
+          .setVersion(Version.withDescription("promo version"));
+
   @Test
   public void testConstructor() {
     Template template = new Template();
@@ -71,66 +117,40 @@ public class TemplateTest {
   @Test
   public void testEquality() {
     final Template templateOne = new Template();
-    final Template templateTwo = new Template();
-
-    assertEquals(templateOne, templateTwo);
-
-    final List<Condition> conditions = ImmutableList.<Condition>of(
-            new Condition("ios_en", "exp ios")
-                    .setTagColor(TagColor.INDIGO),
-            new Condition("android_en", "exp android")
-    );
-    final Map<String, ParameterValue> conditionalValues = ImmutableMap.of(
-            "ios", ParameterValue.of("hello ios"),
-            "android", ParameterValue.of("hello android"),
-            "promo", ParameterValue.inAppDefault()
-    );
-    final Map<String, Parameter> parameters = ImmutableMap.of(
-            "greeting_header", new Parameter()
-                    .setDefaultValue(ParameterValue.inAppDefault())
-                    .setDescription("greeting header text")
-                    .setConditionalValues(conditionalValues),
-            "greeting_text", new Parameter()
-                    .setDefaultValue(ParameterValue.inAppDefault())
-                    .setDescription("greeting text")
-                    .setConditionalValues(conditionalValues)
-    );
-    final Template templateThree = new Template()
-            .setConditions(conditions)
-            .setParameters(parameters);
-    final Template templateFour = new Template()
-            .setConditions(conditions)
-            .setParameters(parameters);
 
-    assertEquals(templateThree, templateFour);
+    assertEquals(EMPTY_TEMPLATE, templateOne);
 
-    final Map<String, ParameterGroup> parameterGroups = ImmutableMap.of(
-            "greetings_group", new ParameterGroup()
-                    .setDescription("description")
-                    .setParameters(parameters)
-    );
-    final Template templateFive = new Template()
-            .setConditions(conditions)
-            .setParameters(parameters)
-            .setParameterGroups(parameterGroups);
-    final Template templateSix = new Template()
-            .setConditions(conditions)
-            .setParameters(parameters)
-            .setParameterGroups(parameterGroups);
+    final Template templateTwo = new Template()
+            .setConditions(CONDITIONS)
+            .setParameters(PARAMETERS);
 
-    assertEquals(templateFive, templateSix);
+    assertEquals(TEMPLATE_WITH_CONDITIONS_PARAMETERS, templateTwo);
 
-    final Template templateSeven = new Template()
-            .setETag("etag-123456789097-20");
-    final Template templateEight = new Template()
+    final Template templateThree = new Template()
+            .setConditions(CONDITIONS)
+            .setParameters(PARAMETERS)
+            .setParameterGroups(PARAMETER_GROUPS);
+
+    assertEquals(TEMPLATE_WITH_CONDITIONS_PARAMETERS_GROUPS, templateThree);
+
+    final Template templateFour = new Template()
             .setETag("etag-123456789097-20");
 
-    assertEquals(templateSeven, templateEight);
+    assertEquals(TEMPLATE_WITH_ETAG, templateFour);
+
+    final Template templateFive = new Template()
+            .setVersion(Version.withDescription("promo version"));
+
+    assertEquals(TEMPLATE_WITH_VERSION, templateFive);
+    assertNotEquals(templateOne, templateTwo);
     assertNotEquals(templateOne, templateThree);
+    assertNotEquals(templateOne, templateFour);
     assertNotEquals(templateOne, templateFive);
-    assertNotEquals(templateOne, templateSeven);
+    assertNotEquals(templateTwo, templateThree);
+    assertNotEquals(templateTwo, templateFour);
+    assertNotEquals(templateTwo, templateFive);
+    assertNotEquals(templateThree, templateFour);
     assertNotEquals(templateThree, templateFive);
-    assertNotEquals(templateThree, templateSeven);
-    assertNotEquals(templateFive, templateSeven);
+    assertNotEquals(templateFour, templateFive);
   }
 }
diff --git a/src/test/java/com/google/firebase/remoteconfig/UserTest.java b/src/test/java/com/google/firebase/remoteconfig/UserTest.java
new file mode 100644
index 0000000..866b30e
--- /dev/null
+++ b/src/test/java/com/google/firebase/remoteconfig/UserTest.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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 com.google.firebase.remoteconfig;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+import org.junit.Test;
+
+public class UserTest {
+
+  @Test(expected = NullPointerException.class)
+  public void testConstructorWithNullVersionResponse() {
+    new User(null);
+  }
+
+  @Test
+  public void testEquality() {
+    final User userOne = new User(new TemplateResponse.UserResponse());
+    final User userTwo = new User(new TemplateResponse.UserResponse());
+
+    assertEquals(userOne, userTwo);
+
+    final User userThree = new User(new TemplateResponse.UserResponse()
+            .setName("admin-user")
+            .setEmail("admin@email.com")
+            .setImageUrl("http://admin.jpg"));
+    final User userFour = new User(new TemplateResponse.UserResponse()
+            .setName("admin-user")
+            .setEmail("admin@email.com")
+            .setImageUrl("http://admin.jpg"));
+
+    assertEquals(userThree, userFour);
+
+    final User userFive = new User(new TemplateResponse.UserResponse()
+            .setName("admin-user")
+            .setEmail("admin@email.com"));
+    final User userSix = new User(new TemplateResponse.UserResponse()
+            .setName("admin-user")
+            .setEmail("admin@email.com"));
+
+    assertEquals(userFive, userSix);
+    assertNotEquals(userOne, userThree);
+    assertNotEquals(userOne, userFive);
+    assertNotEquals(userThree, userFive);
+  }
+}
diff --git a/src/test/java/com/google/firebase/remoteconfig/VersionTest.java b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java
new file mode 100644
index 0000000..c0a6004
--- /dev/null
+++ b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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 com.google.firebase.remoteconfig;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+import com.google.firebase.remoteconfig.internal.TemplateResponse.VersionResponse;
+import org.junit.Test;
+
+public class VersionTest {
+
+  @Test(expected = NullPointerException.class)
+  public void testConstructorWithNullVersionResponse() {
+    new Version(null);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testConstructorWithInvalidUpdateTime() {
+    new Version(new VersionResponse()
+            .setUpdateTime("sunday,26th"));
+  }
+
+  @Test
+  public void testWithDescription() {
+    final Version version = Version.withDescription("version description text");
+
+    assertEquals("version description text", version.getDescription());
+    assertNull(version.getVersionNumber());
+    assertEquals(0, version.getUpdateTime());
+    assertNull(version.getUpdateOrigin());
+    assertNull(version.getUpdateType());
+    assertNull(version.getUpdateUser());
+    assertNull(version.getRollbackSource());
+    assertFalse(version.isLegacy());
+  }
+
+  @Test
+  public void testEquality() {
+    final Version versionOne = new Version(new VersionResponse());
+    final Version versionTwo = new Version(new VersionResponse());
+
+    assertEquals(versionOne, versionTwo);
+
+    final Version versionThree = Version.withDescription("abcd");
+    final Version versionFour = Version.withDescription("abcd");
+    final Version versionFive = new Version(new VersionResponse()).setDescription("abcd");
+
+    assertEquals(versionThree, versionFour);
+    assertEquals(versionThree, versionFive);
+
+    final Version versionSix = Version.withDescription("efgh");
+
+    assertNotEquals(versionThree, versionSix);
+    assertNotEquals(versionOne, versionSix);
+
+    final VersionResponse versionResponse = new VersionResponse()
+            .setVersionNumber("23")
+            .setUpdateTime("2014-10-02T15:01:23.045123456Z")
+            .setUpdateOrigin("ADMIN_SDK")
+            .setUpdateUser(new TemplateResponse.UserResponse()
+                    .setEmail("user@email.com")
+                    .setName("user-1234")
+                    .setImageUrl("http://user.jpg"))
+            .setUpdateType("INCREMENTAL_UPDATE");
+    final Version versionSeven = new Version(versionResponse);
+    final Version versionEight = new Version(versionResponse);
+
+    assertEquals(versionSeven, versionEight);
+    assertNotEquals(versionOne, versionSeven);
+    assertNotEquals(versionThree, versionSeven);
+    assertNotEquals(versionSix, versionSeven);
+  }
+}
diff --git a/src/test/resources/getRemoteConfig.json b/src/test/resources/getRemoteConfig.json
index 8b0f664..cefc587 100644
--- a/src/test/resources/getRemoteConfig.json
+++ b/src/test/resources/getRemoteConfig.json
@@ -46,8 +46,11 @@
     "updateOrigin": "ADMIN_SDK_NODE",
     "updateType": "INCREMENTAL_UPDATE",
     "updateUser": {
-      "email": "firebase-user@account.com"
+      "email": "firebase-user@account.com",
+      "name": "dev-admin",
+      "imageUrl": "http://image.jpg"
     },
-    "updateTime": "Wed, 30 Sep 2020 17:56:07 GMT"
+    "updateTime": "2020-11-03T20:24:15.045123456ZZ",
+    "description": "promo config"
   }
 }