Repository: firebase/firebase-admin-java
Pull request #496 (2020-11-23 20:49:55)

Add Remote Config Pubish, Validate, and GetTemplateAtVersion operations
- Add `getTemplateAtVersion()`, `publishTemplate()`, `validateTemplate()`, and `forcePublishTemplate()` operations.
- Add unit tests for new operations.
- Add new unit test for timestamps with up to nanoseconds precision.

Related to: #446 


Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
index f910d39..c82693d 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
@@ -24,6 +24,7 @@ import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.internal.CallableOperation;
 import com.google.firebase.internal.FirebaseService;
+import com.google.firebase.internal.NonNull;
 
 /**
  * This class is the entry point for all server-side Firebase Remote Config actions.
@@ -100,6 +101,160 @@ public final class FirebaseRemoteConfig {
     };
   }
 
+  /**
+   * Gets the requested version of the of the Remote Config template.
+   *
+   * @param versionNumber The version number of the Remote Config template to look up.
+   * @return A {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while getting the template.
+   */
+  public Template getTemplateAtVersion(
+          @NonNull String versionNumber) throws FirebaseRemoteConfigException {
+    return getTemplateAtVersionOp(versionNumber).call();
+  }
+
+  /**
+   * Gets the requested version of the of the Remote Config template.
+   *
+   * @param versionNumber The version number of the Remote Config template to look up.
+   * @return A {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while getting the template.
+   */
+  public Template getTemplateAtVersion(long versionNumber) throws FirebaseRemoteConfigException {
+    String versionNumberString = String.valueOf(versionNumber);
+    return getTemplateAtVersionOp(versionNumberString).call();
+  }
+
+  /**
+   * Similar to {@link #getTemplateAtVersion(String versionNumber)} but performs the operation
+   * asynchronously.
+   *
+   * @param versionNumber The version number of the Remote Config template to look up.
+   * @return An {@code ApiFuture} that completes with a {@link Template} when
+   *     the requested template is available.
+   */
+  public ApiFuture<Template> getTemplateAtVersionAsync(@NonNull String versionNumber) {
+    return getTemplateAtVersionOp(versionNumber).callAsync(app);
+  }
+
+  /**
+   * Similar to {@link #getTemplateAtVersion(long versionNumber)} but performs the operation
+   * asynchronously.
+   *
+   * @param versionNumber The version number of the Remote Config template to look up.
+   * @return An {@code ApiFuture} that completes with a {@link Template} when
+   *     the requested template is available.
+   */
+  public ApiFuture<Template> getTemplateAtVersionAsync(long versionNumber) {
+    String versionNumberString = String.valueOf(versionNumber);
+    return getTemplateAtVersionOp(versionNumberString).callAsync(app);
+  }
+
+  private CallableOperation<Template, FirebaseRemoteConfigException> getTemplateAtVersionOp(
+          final String versionNumber) {
+    final FirebaseRemoteConfigClient remoteConfigClient = getRemoteConfigClient();
+    return new CallableOperation<Template, FirebaseRemoteConfigException>() {
+      @Override
+      protected Template execute() throws FirebaseRemoteConfigException {
+        return remoteConfigClient.getTemplateAtVersion(versionNumber);
+      }
+    };
+  }
+
+  /**
+   * Publishes a Remote Config template.
+   *
+   * @param template The Remote Config template to be published.
+   * @return The published {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while publishing the template.
+   */
+  public Template publishTemplate(@NonNull Template template) throws FirebaseRemoteConfigException {
+    return publishTemplateOp(template).call();
+  }
+
+  /**
+   * Similar to {@link #publishTemplate(Template template)} but performs the operation
+   * asynchronously.
+   *
+   * @param template The Remote Config template to be published.
+   * @return An {@code ApiFuture} that completes with a {@link Template} when
+   *     the provided template is published.
+   */
+  public ApiFuture<Template> publishTemplateAsync(@NonNull Template template) {
+    return publishTemplateOp(template).callAsync(app);
+  }
+
+  /**
+   * Validates a Remote Config template.
+   *
+   * @param template The Remote Config template to be validated.
+   * @return The validated {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while validating the template.
+   */
+  public Template validateTemplate(
+          @NonNull Template template) throws FirebaseRemoteConfigException {
+    return publishTemplateOp(template, new PublishOptions().setValidateOnly(true)).call();
+  }
+
+  /**
+   * Similar to {@link #validateTemplate(Template template)} but performs the operation
+   * asynchronously.
+   *
+   * @param template The Remote Config template to be validated.
+   * @return An {@code ApiFuture} that completes with a {@link Template} when
+   *     the provided template is validated.
+   */
+  public ApiFuture<Template> validateTemplateAsync(@NonNull Template template) {
+    return publishTemplateOp(template, new PublishOptions().setValidateOnly(true)).callAsync(app);
+  }
+
+  /**
+   * Force publishes a Remote Config template.
+   *
+   * <p>This method forces the Remote Config template to be updated and circumvent the ETag.
+   * This approach is not recommended because it risks causing the loss of updates to your
+   * Remote Config template if multiple clients are updating the Remote Config template.
+   * See <a href="https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates">
+   * ETag usage and forced updates</a>.
+   *
+   * @param template The Remote Config template to be forcefully published.
+   * @return The published {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while publishing the template.
+   */
+  public Template forcePublishTemplate(
+          @NonNull Template template) throws FirebaseRemoteConfigException {
+    return publishTemplateOp(template, new PublishOptions().setForcePublish(true)).call();
+  }
+
+  /**
+   * Similar to {@link #forcePublishTemplate(Template template)} but performs the operation
+   * asynchronously.
+   *
+   * @param template The Remote Config template to be forcefully published.
+   * @return An {@code ApiFuture} that completes with a {@link Template} when
+   *     the provided template is published.
+   */
+  public ApiFuture<Template> forcePublishTemplateAsync(@NonNull Template template) {
+    return publishTemplateOp(template, new PublishOptions().setForcePublish(true)).callAsync(app);
+  }
+
+  private CallableOperation<Template, FirebaseRemoteConfigException> publishTemplateOp(
+          final Template template) {
+    return publishTemplateOp(template, new PublishOptions());
+  }
+
+  private CallableOperation<Template, FirebaseRemoteConfigException> publishTemplateOp(
+          final Template template, final PublishOptions options) {
+    final FirebaseRemoteConfigClient remoteConfigClient = getRemoteConfigClient();
+    return new CallableOperation<Template, FirebaseRemoteConfigException>() {
+      @Override
+      protected Template execute() throws FirebaseRemoteConfigException {
+        return remoteConfigClient
+                .publishTemplate(template, options.isValidateOnly(), options.isForcePublish());
+      }
+    };
+  }
+
   @VisibleForTesting
   FirebaseRemoteConfigClient getRemoteConfigClient() {
     return remoteConfigClient;
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
index a00c5af..a218218 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
@@ -28,4 +28,9 @@ interface FirebaseRemoteConfigClient {
    * @throws FirebaseRemoteConfigException If an error occurs while getting the template.
    */
   Template getTemplate() throws FirebaseRemoteConfigException;
+
+  Template getTemplateAtVersion(String versionNumber) throws FirebaseRemoteConfigException;
+
+  Template publishTemplate(Template template, boolean validateOnly,
+                                  boolean forcePublish) throws FirebaseRemoteConfigException;
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
index 5f98c77..c289b43 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
@@ -22,6 +22,7 @@ import static com.google.common.base.Preconditions.checkState;
 
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.json.JsonHttpContent;
 import com.google.api.client.json.JsonFactory;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
@@ -34,6 +35,7 @@ import com.google.firebase.internal.AbstractPlatformErrorHandler;
 import com.google.firebase.internal.ApiClientUtils;
 import com.google.firebase.internal.ErrorHandlingHttpClient;
 import com.google.firebase.internal.HttpRequestInfo;
+import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.SdkUtils;
 import com.google.firebase.remoteconfig.internal.RemoteConfigServiceErrorResponse;
 import com.google.firebase.remoteconfig.internal.TemplateResponse;
@@ -100,6 +102,42 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
     return template.setETag(getETag(response));
   }
 
+  @Override
+  public Template getTemplateAtVersion(String versionNumber) throws FirebaseRemoteConfigException {
+    checkArgument(isValidVersionNumber(versionNumber),
+            "Version number must be a non-empty string in int64 format.");
+    HttpRequestInfo request = HttpRequestInfo.buildGetRequest(remoteConfigUrl)
+            .addAllHeaders(COMMON_HEADERS)
+            .addParameter("versionNumber", versionNumber);
+    IncomingHttpResponse response = httpClient.send(request);
+    TemplateResponse templateResponse = httpClient.parse(response, TemplateResponse.class);
+    Template template = new Template(templateResponse);
+    return template.setETag(getETag(response));
+  }
+
+  @Override
+  public Template publishTemplate(@NonNull Template template, boolean validateOnly,
+                                  boolean forcePublish) throws FirebaseRemoteConfigException {
+    checkArgument(template != null, "Template must not be null.");
+    HttpRequestInfo request = HttpRequestInfo.buildRequest("PUT", remoteConfigUrl,
+            new JsonHttpContent(jsonFactory, template.toTemplateResponse()))
+            .addAllHeaders(COMMON_HEADERS)
+            .addHeader("If-Match", forcePublish ? "*" : template.getETag());
+    if (validateOnly) {
+      request.addParameter("validateOnly", true);
+    }
+    IncomingHttpResponse response = httpClient.send(request);
+    TemplateResponse templateResponse = httpClient.parse(response, TemplateResponse.class);
+    Template publishedTemplate = new Template(templateResponse);
+    if (validateOnly) {
+      // validating a template returns an etag with the suffix -0 means that the provided template
+      // was successfully validated. We set the etag back to the original etag of the template
+      // to allow subsequent operations.
+      return publishedTemplate.setETag(template.getETag());
+    }
+    return publishedTemplate.setETag(getETag(response));
+  }
+
   private String getETag(IncomingHttpResponse response) {
     List<String> etagList = (List<String>) response.getHeaders().get("etag");
     checkState(etagList != null && !etagList.isEmpty(),
@@ -112,6 +150,10 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
     return etag;
   }
 
+  private boolean isValidVersionNumber(String versionNumber) {
+    return !Strings.isNullOrEmpty(versionNumber) && versionNumber.matches("^\\d+$");
+  }
+
   static FirebaseRemoteConfigClientImpl fromApp(FirebaseApp app) {
     String projectId = ImplFirebaseTrampolines.getProjectId(app);
     checkArgument(!Strings.isNullOrEmpty(projectId),
diff --git a/src/main/java/com/google/firebase/remoteconfig/PublishOptions.java b/src/main/java/com/google/firebase/remoteconfig/PublishOptions.java
new file mode 100644
index 0000000..78c25b8
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/PublishOptions.java
@@ -0,0 +1,33 @@
+package com.google.firebase.remoteconfig;
+
+/**
+ * An internal class for publish template options.
+ */
+final class PublishOptions {
+
+  private boolean validateOnly;
+  private boolean forcePublish;
+
+  PublishOptions() {
+    validateOnly = false;
+    forcePublish = false;
+  }
+
+  public PublishOptions setForcePublish(boolean forcePublish) {
+    this.forcePublish = forcePublish;
+    return this;
+  }
+
+  public PublishOptions setValidateOnly(boolean validateOnly) {
+    this.validateOnly = validateOnly;
+    return this;
+  }
+
+  public boolean isForcePublish() {
+    return forcePublish;
+  }
+
+  public boolean isValidateOnly() {
+    return validateOnly;
+  }
+}


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 4c1bac0..6ca583b 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -17,6 +17,7 @@
 package com.google.firebase.remoteconfig;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
@@ -46,11 +47,8 @@ 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;
@@ -72,6 +70,55 @@ public class FirebaseRemoteConfigClientImplTest {
 
   private static final String TEST_ETAG = "etag-123456789012-1";
 
+  private static final Map<String, Parameter> EXPECTED_PARAMETERS = ImmutableMap.of(
+          "welcome_message_text", new Parameter()
+                  .setDefaultValue(ParameterValue.of("welcome to app"))
+                  .setConditionalValues(ImmutableMap.<String, ParameterValue>of(
+                          "ios_en", ParameterValue.of("welcome to app en")
+                  ))
+                  .setDescription("text for welcome message!"),
+          "header_text", new Parameter()
+                  .setDefaultValue(ParameterValue.inAppDefault())
+  );
+
+  private static final Map<String, ParameterGroup> EXPECTED_PARAMETER_GROUPS = ImmutableMap.of(
+          "new menu", new ParameterGroup()
+                  .setDescription("New Menu")
+                  .setParameters(ImmutableMap.of(
+                          "pumpkin_spice_season", new Parameter()
+                                  .setDefaultValue(ParameterValue.of("true"))
+                                  .setDescription("Whether it's currently pumpkin spice season.")
+                          )
+                  )
+  );
+
+  private static final List<Condition> EXPECTED_CONDITIONS = 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)
+  );
+
+  private static final Version EXPECTED_VERSION = 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-15T06:57:26.342763941Z")
+          .setDescription("promo config")
+  );
+
+  private static final Template EXPECTED_TEMPLATE = new Template()
+          .setETag(TEST_ETAG)
+          .setParameters(EXPECTED_PARAMETERS)
+          .setConditions(EXPECTED_CONDITIONS)
+          .setParameterGroups(EXPECTED_PARAMETER_GROUPS)
+          .setVersion(EXPECTED_VERSION);
+
   private MockLowLevelHttpResponse response;
   private TestResponseInterceptor interceptor;
   private FirebaseRemoteConfigClient client;
@@ -83,64 +130,45 @@ public class FirebaseRemoteConfigClientImplTest {
     client = initRemoteConfigClient(response, interceptor);
   }
 
+  // Get template tests
+
   @Test
   public void testGetTemplate() throws Exception {
     response.addHeader("etag", TEST_ETAG);
     response.setContent(MOCK_TEMPLATE_RESPONSE);
 
     Template receivedTemplate = client.getTemplate();
-    final Map<String, Parameter> expectedParameters = ImmutableMap.of(
-            "welcome_message_text", new Parameter()
-                    .setDefaultValue(ParameterValue.of("welcome to app"))
-                    .setConditionalValues(ImmutableMap.<String, ParameterValue>of(
-                            "ios_en", ParameterValue.of("welcome to app en")
-                    ))
-                    .setDescription("text for welcome message!"),
-            "header_text", new Parameter()
-                    .setDefaultValue(ParameterValue.inAppDefault())
-    );
-    final Map<String, ParameterGroup> expectedParameterGroups = ImmutableMap.of(
-            "new menu", new ParameterGroup()
-                    .setDescription("New Menu")
-                    .setParameters(ImmutableMap.of(
-                            "pumpkin_spice_season", new Parameter()
-                                    .setDefaultValue(ParameterValue.of("true"))
-                                    .setDescription("Whether it's currently pumpkin spice season.")
-                            )
-                    )
-    );
-    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)
-            .setConditions(expectedConditions)
-            .setParameterGroups(expectedParameterGroups)
-            .setVersion(expectedVersion);
 
     assertEquals(TEST_ETAG, receivedTemplate.getETag());
-    assertEquals(expectedTemplate, receivedTemplate);
-    assertEquals(1604435055000L, receivedTemplate.getVersion().getUpdateTime());
+    assertEquals(EXPECTED_TEMPLATE, receivedTemplate);
+    assertEquals(1605423446000L, receivedTemplate.getVersion().getUpdateTime());
     checkGetRequestHeader(interceptor.getLastRequest());
   }
 
+  @Test
+  public void testGetTemplateWithTimestampUpToNanosecondPrecision() throws Exception {
+    List<String> timestamps = ImmutableList.of(
+            "2020-11-15T06:57:26.342Z",
+            "2020-11-15T06:57:26.342763Z",
+            "2020-11-15T06:57:26.342763941Z"
+    );
+    for (String timestamp : timestamps) {
+      response.addHeader("etag", TEST_ETAG);
+      String templateResponse = "{\"version\": {"
+              + "    \"versionNumber\": \"17\","
+              + "    \"updateTime\": \"" + timestamp + "\""
+              + "  }}";
+      response.setContent(templateResponse);
+
+      Template receivedTemplate = client.getTemplate();
+
+      assertEquals(TEST_ETAG, receivedTemplate.getETag());
+      assertEquals("17", receivedTemplate.getVersion().getVersionNumber());
+      assertEquals(1605423446000L, receivedTemplate.getVersion().getUpdateTime());
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
   @Test
   public void testGetTemplateWithEmptyTemplateResponse() throws Exception {
     response.addHeader("etag", TEST_ETAG);
@@ -150,16 +178,22 @@ public class FirebaseRemoteConfigClientImplTest {
 
     assertEquals(TEST_ETAG, template.getETag());
     assertEquals(0, template.getParameters().size());
+    assertEquals(0, template.getConditions().size());
+    assertEquals(0, template.getParameterGroups().size());
+    assertNull(template.getVersion());
     checkGetRequestHeader(interceptor.getLastRequest());
   }
 
   @Test(expected = IllegalStateException.class)
-  public void testGetTemplateWithInvalidEtags() throws FirebaseRemoteConfigException {
+  public void testGetTemplateWithNoEtag() throws FirebaseRemoteConfigException {
     // ETag does not exist
     response.setContent(MOCK_TEMPLATE_RESPONSE);
 
     client.getTemplate();
+  }
 
+  @Test(expected = IllegalStateException.class)
+  public void testGetTemplateWithEmptyEtag() throws FirebaseRemoteConfigException {
     // Empty ETag
     response.addHeader("etag", "");
     response.setContent(MOCK_TEMPLATE_RESPONSE);
@@ -177,7 +211,7 @@ public class FirebaseRemoteConfigClientImplTest {
         fail("No error thrown for HTTP error");
       } catch (FirebaseRemoteConfigException error) {
         checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
-                "Unexpected HTTP response with status: " + code + "\n{}");
+                "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET);
       }
       checkGetRequestHeader(interceptor.getLastRequest());
     }
@@ -227,7 +261,7 @@ public class FirebaseRemoteConfigClientImplTest {
         fail("No error thrown for HTTP error");
       } catch (FirebaseRemoteConfigException error) {
         checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
-                "Unexpected HTTP response with status: " + code + "\nnull");
+                "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET);
       }
       checkGetRequestHeader(interceptor.getLastRequest());
     }
@@ -243,7 +277,7 @@ public class FirebaseRemoteConfigClientImplTest {
         fail("No error thrown for HTTP error");
       } catch (FirebaseRemoteConfigException error) {
         checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
-                "Unexpected HTTP response with status: " + code + "\nnot json");
+                "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET);
       }
       checkGetRequestHeader(interceptor.getLastRequest());
     }
@@ -259,7 +293,8 @@ public class FirebaseRemoteConfigClientImplTest {
         client.getTemplate();
         fail("No error thrown for HTTP error");
       } catch (FirebaseRemoteConfigException error) {
-        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error");
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error",
+                HttpMethods.GET);
       }
       checkGetRequestHeader(interceptor.getLastRequest());
     }
@@ -277,12 +312,405 @@ public class FirebaseRemoteConfigClientImplTest {
         fail("No error thrown for HTTP error");
       } catch (FirebaseRemoteConfigException error) {
         checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
-                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error");
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error",
+                HttpMethods.GET);
       }
       checkGetRequestHeader(interceptor.getLastRequest());
     }
   }
 
+  // Test getTemplateAtVersion
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testGetTemplateAtVersionWithNullString() throws Exception {
+    client.getTemplateAtVersion(null);
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithInvalidString() throws Exception {
+    List<String> invalidVersionStrings = ImmutableList
+            .of("", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4");
+
+    for (String version : invalidVersionStrings) {
+      try {
+        client.getTemplateAtVersion(version);
+        fail("No error thrown for invalid version number");
+      } catch (IllegalArgumentException expected) {
+        String message = "Version number must be a non-empty string in int64 format.";
+        assertEquals(message, expected.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithValidString() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    Template receivedTemplate = client.getTemplateAtVersion("24");
+
+    assertEquals(TEST_ETAG, receivedTemplate.getETag());
+    assertEquals(EXPECTED_TEMPLATE, receivedTemplate);
+    assertEquals(1605423446000L, receivedTemplate.getVersion().getUpdateTime());
+    checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithEmptyTemplateResponse() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent("{}");
+
+    Template template = client.getTemplateAtVersion("24");
+
+    assertEquals(TEST_ETAG, template.getETag());
+    assertEquals(0, template.getParameters().size());
+    assertEquals(0, template.getConditions().size());
+    assertEquals(0, template.getParameterGroups().size());
+    assertNull(template.getVersion());
+    checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testGetTemplateAtVersionWithNoEtag() throws FirebaseRemoteConfigException {
+    // ETag does not exist
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.getTemplateAtVersion("24");
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testGetTemplateAtVersionWithEmptyEtag() throws FirebaseRemoteConfigException {
+    // Empty ETag
+    response.addHeader("etag", "");
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.getTemplateAtVersion("24");
+  }
+
+  @Test
+  public void testGetTemplateAtVersionHttpError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.getTemplateAtVersion("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.getTemplateAtVersion("24");
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertEquals("Unknown error while making a remote service call: transport error",
+              error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+      assertNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionSuccessResponseWithUnexpectedPayload() {
+    response.setContent("not valid json");
+
+    try {
+      client.getTemplateAtVersion("24");
+      fail("No error thrown for malformed response");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertTrue(error.getMessage().startsWith("Error while parsing HTTP response: "));
+      assertNotNull(error.getCause());
+      assertNotNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+    checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+  }
+
+  @Test
+  public void testGetTemplateAtVersionErrorWithZeroContentResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.getTemplateAtVersion("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionErrorWithMalformedResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.getTemplateAtVersion("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionErrorWithDetails() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.getTemplateAtVersion("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error",
+                HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionErrorWithRcError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+                      + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}");
+
+      try {
+        client.getTemplateAtVersion("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error",
+                HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24");
+    }
+  }
+
+  // Test publishTemplate
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testPublishTemplateWithNullTemplate() throws Exception {
+    client.publishTemplate(null, false, false);
+  }
+
+  @Test
+  public void testPublishTemplateWithValidTemplate() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    Template publishedTemplate = client.publishTemplate(EXPECTED_TEMPLATE, false, false);
+
+    assertEquals(TEST_ETAG, publishedTemplate.getETag());
+    assertEquals(EXPECTED_TEMPLATE, publishedTemplate);
+    assertEquals(1605423446000L, publishedTemplate.getVersion().getUpdateTime());
+    checkPutRequestHeader(interceptor.getLastRequest());
+  }
+
+  @Test
+  public void testPublishTemplateWithValidTemplateAndForceTrue() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    Template publishedTemplate = client.publishTemplate(EXPECTED_TEMPLATE, false, true);
+
+    assertEquals(TEST_ETAG, publishedTemplate.getETag());
+    assertEquals(EXPECTED_TEMPLATE, publishedTemplate);
+    assertEquals(1605423446000L, publishedTemplate.getVersion().getUpdateTime());
+    checkPutRequestHeader(interceptor.getLastRequest(), "", "*");
+  }
+
+  @Test
+  public void testPublishTemplateWithValidTemplateAndValidateOnlyTrue() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+    Template expectedTemplate = new Template()
+            .setETag("etag-123456789012-45")
+            .setParameters(EXPECTED_PARAMETERS)
+            .setConditions(EXPECTED_CONDITIONS)
+            .setParameterGroups(EXPECTED_PARAMETER_GROUPS)
+            .setVersion(EXPECTED_VERSION);
+
+    Template validatedTemplate = client.publishTemplate(expectedTemplate, true, false);
+
+    // check if the etag matches the input template's etag and not the etag from the server response
+    assertNotEquals(TEST_ETAG, validatedTemplate.getETag());
+    assertEquals("etag-123456789012-45", validatedTemplate.getETag());
+    assertEquals(expectedTemplate, validatedTemplate);
+    checkPutRequestHeader(interceptor.getLastRequest(), "?validateOnly=true",
+            "etag-123456789012-45");
+  }
+
+  @Test
+  public void testPublishTemplateWithEmptyTemplateResponse() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent("{}");
+
+    Template template = client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+
+    assertEquals(TEST_ETAG, template.getETag());
+    assertEquals(0, template.getParameters().size());
+    assertEquals(0, template.getConditions().size());
+    assertEquals(0, template.getParameterGroups().size());
+    assertNull(template.getVersion());
+    checkPutRequestHeader(interceptor.getLastRequest());
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testPublishTemplateWithNoEtag() throws FirebaseRemoteConfigException {
+    // ETag does not exist
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.publishTemplate(new Template(), false, false);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testPublishTemplateWithEmptyEtag() throws FirebaseRemoteConfigException {
+    // Empty ETag
+    response.addHeader("etag", "");
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.publishTemplate(new Template(), false, false);
+  }
+
+  @Test
+  public void testPublishTemplateHttpError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.PUT);
+      }
+      checkPutRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testPublishTemplateTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.publishTemplate(new Template(), false, false);
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertEquals("Unknown error while making a remote service call: transport error",
+              error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+      assertNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+  }
+
+  @Test
+  public void testPublishTemplateSuccessResponseWithUnexpectedPayload() {
+    response.setContent("not valid json");
+
+    try {
+      client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+      fail("No error thrown for malformed response");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertTrue(error.getMessage().startsWith("Error while parsing HTTP response: "));
+      assertNotNull(error.getCause());
+      assertNotNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+    checkPutRequestHeader(interceptor.getLastRequest());
+  }
+
+  @Test
+  public void testPublishTemplateErrorWithZeroContentResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.PUT);
+      }
+      checkPutRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testPublishTemplateErrorWithMalformedResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.PUT);
+      }
+      checkPutRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testPublishTemplateErrorWithDetails() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error",
+                HttpMethods.PUT);
+      }
+      checkPutRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testPublishTemplateErrorWithRcError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+                      + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}");
+
+      try {
+        client.publishTemplate(new Template().setETag(TEST_ETAG), false, false);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error",
+                HttpMethods.PUT);
+      }
+      checkPutRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  // App related tests
+
   @Test(expected = IllegalArgumentException.class)
   public void testBuilderNullProjectId() {
     fullyPopulatedBuilder().setProjectId(null).build();
@@ -351,18 +779,36 @@ public class FirebaseRemoteConfigClientImplTest {
   }
 
   private void checkGetRequestHeader(HttpRequest request) {
+    checkGetRequestHeader(request, "");
+  }
+
+  private void checkGetRequestHeader(HttpRequest request, String query) {
     assertEquals("GET", request.getRequestMethod());
-    assertEquals(TEST_REMOTE_CONFIG_URL, request.getUrl().toString());
+    assertEquals(TEST_REMOTE_CONFIG_URL + query, request.getUrl().toString());
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
+    assertEquals("gzip", headers.getAcceptEncoding());
+  }
+
+  private void checkPutRequestHeader(HttpRequest request) {
+    checkPutRequestHeader(request, "", TEST_ETAG);
+  }
+
+  private void checkPutRequestHeader(HttpRequest request, String query, String ifMatch) {
+    assertEquals("PUT", request.getRequestMethod());
+    assertEquals(TEST_REMOTE_CONFIG_URL + query, request.getUrl().toString());
     HttpHeaders headers = request.getHeaders();
     assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
     assertEquals("gzip", headers.getAcceptEncoding());
+    assertEquals(ifMatch, headers.getIfMatch());
   }
 
   private void checkExceptionFromHttpResponse(
           FirebaseRemoteConfigException error,
           ErrorCode expectedCode,
           RemoteConfigErrorCode expectedRemoteConfigCode,
-          String expectedMessage) {
+          String expectedMessage,
+          String httpMethod) {
     assertEquals(expectedCode, error.getErrorCode());
     assertEquals(expectedMessage, error.getMessage());
     assertTrue(error.getCause() instanceof HttpResponseException);
@@ -370,7 +816,7 @@ public class FirebaseRemoteConfigClientImplTest {
 
     assertNotNull(error.getHttpResponse());
     OutgoingHttpRequest request = error.getHttpResponse().getRequest();
-    assertEquals(HttpMethods.GET, request.getMethod());
+    assertEquals(httpMethod, request.getMethod());
     assertTrue(request.getUrl().startsWith("https://firebaseremoteconfig.googleapis.com"));
   }
 }
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
index ec2dc74..ed9eaf2 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
@@ -27,7 +27,9 @@ import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
 import com.google.firebase.auth.MockGoogleCredentials;
+
 import java.util.concurrent.ExecutionException;
+
 import org.junit.After;
 import org.junit.Test;
 
@@ -113,6 +115,8 @@ public class FirebaseRemoteConfigTest {
 
   private static final String TEST_ETAG = "etag-123456789012-1";
 
+  // Get template tests
+
   @Test
   public void testGetTemplate() throws FirebaseRemoteConfigException {
     MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
@@ -159,6 +163,244 @@ public class FirebaseRemoteConfigTest {
     }
   }
 
+  // Get template with version number tests
+
+  @Test
+  public void testGetTemplateAtVersionWithStringValue() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.getTemplateAtVersion("64");
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithStringValueFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.getTemplateAtVersion("55");
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionAsyncWithStringValue() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.getTemplateAtVersionAsync("55").get();
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testGetTemplateAtVersionAsyncWithStringValueFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.getTemplateAtVersionAsync("55").get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithLongValue() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.getTemplateAtVersion(64L);
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testGetTemplateAtVersionWithLongValueFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.getTemplateAtVersion(55L);
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testGetTemplateAtVersionAsyncWithLongValue() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.getTemplateAtVersionAsync(55L).get();
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testGetTemplateAtVersionAsyncWithLongValueFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.getTemplateAtVersionAsync(55L).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  // Validate template tests
+
+  @Test
+  public void testValidateTemplate() throws FirebaseRemoteConfigException {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template validatedTemplate = remoteConfig.validateTemplate(template);
+
+    assertEquals(TEST_ETAG, validatedTemplate.getETag());
+  }
+
+  @Test
+  public void testValidateTemplateFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.validateTemplate(new Template());
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testValidateTemplateAsync() throws Exception {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template validatedTemplate = remoteConfig.validateTemplateAsync(template).get();
+
+    assertEquals(TEST_ETAG, validatedTemplate.getETag());
+  }
+
+  @Test
+  public void testValidateTemplateAsyncFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.validateTemplateAsync(new Template()).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  // Publish template tests
+
+  @Test
+  public void testPublishTemplate() throws FirebaseRemoteConfigException {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template publishedTemplate = remoteConfig.publishTemplate(template);
+
+    assertEquals(TEST_ETAG, publishedTemplate.getETag());
+  }
+
+  @Test
+  public void testPublishTemplateFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.publishTemplate(new Template());
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testPublishTemplateAsync() throws Exception {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template publishedTemplate = remoteConfig.publishTemplateAsync(template).get();
+
+    assertEquals(TEST_ETAG, publishedTemplate.getETag());
+  }
+
+  @Test
+  public void testPublishTemplateAsyncFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.publishTemplateAsync(new Template()).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  // Force publish template tests
+
+  @Test
+  public void testForcePublishTemplate() throws FirebaseRemoteConfigException {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template forcePublishedTemplate = remoteConfig.forcePublishTemplate(template);
+
+    assertEquals(TEST_ETAG, forcePublishedTemplate.getETag());
+  }
+
+  @Test
+  public void testForcePublishTemplateFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.forcePublishTemplate(new Template());
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testForcePublishTemplateAsync() throws Exception {
+    Template template = new Template().setETag(TEST_ETAG);
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template forcePublishedTemplate = remoteConfig.forcePublishTemplateAsync(template).get();
+
+    assertEquals(TEST_ETAG, forcePublishedTemplate.getETag());
+  }
+
+  @Test
+  public void testForcePublishTemplateAsyncFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.forcePublishTemplateAsync(new Template()).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
   private FirebaseRemoteConfig getRemoteConfig(FirebaseRemoteConfigClient client) {
     FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
     return new FirebaseRemoteConfig(app, client);
diff --git a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
index de58f2b..4dc136b 100644
--- a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
+++ b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
@@ -42,4 +42,21 @@ public class MockRemoteConfigClient implements FirebaseRemoteConfigClient{
     }
     return resultTemplate;
   }
+
+  @Override
+  public Template getTemplateAtVersion(String versionNumber) throws FirebaseRemoteConfigException {
+    if (exception != null) {
+      throw exception;
+    }
+    return resultTemplate;
+  }
+
+  @Override
+  public Template publishTemplate(Template template, boolean validateOnly,
+                                  boolean forcePublish) throws FirebaseRemoteConfigException {
+    if (exception != null) {
+      throw exception;
+    }
+    return resultTemplate;
+  }
 }
diff --git a/src/test/resources/getRemoteConfig.json b/src/test/resources/getRemoteConfig.json
index cefc587..a4969f0 100644
--- a/src/test/resources/getRemoteConfig.json
+++ b/src/test/resources/getRemoteConfig.json
@@ -50,7 +50,7 @@
       "name": "dev-admin",
       "imageUrl": "http://image.jpg"
     },
-    "updateTime": "2020-11-03T20:24:15.045123456ZZ",
+    "updateTime": "2020-11-15T06:57:26.342763941Z",
     "description": "promo config"
   }
 }