diff --git a/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/LocalResourceManagerHelper.java b/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/LocalResourceManagerHelper.java
new file mode 100644
index 000000000000..beb824ac812d
--- /dev/null
+++ b/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/LocalResourceManagerHelper.java
@@ -0,0 +1,573 @@
+package com.google.gcloud.resourcemanager.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.net.HttpURLConnection.HTTP_OK;
+
+import com.google.api.client.json.JsonFactory;
+import com.google.api.services.cloudresourcemanager.model.Project;
+import com.google.common.base.Joiner;
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.ByteStreams;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+
+import org.joda.time.format.ISODateTimeFormat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Utility to create a local Resource Manager mock for testing.
+ *
+ *
The mock runs in a separate thread, listening for HTTP requests on the local machine at an
+ * ephemeral port.
+ */
+@SuppressWarnings("restriction")
+public class LocalResourceManagerHelper {
+ private static final Logger log = Logger.getLogger(LocalResourceManagerHelper.class.getName());
+ private static final JsonFactory jsonFactory =
+ new com.google.api.client.json.jackson.JacksonFactory();
+ private static final Random PROJECT_NUMBER_GENERATOR = new Random();
+ private static final String VERSION = "v1beta1";
+ private static final String CONTEXT = "/" + VERSION + "/projects";
+ private static final URI BASE_CONTEXT;
+ private static final Set SUPPORTED_COMPRESSION_ENCODINGS =
+ ImmutableSet.of("gzip", "x-gzip");
+
+ static {
+ try {
+ BASE_CONTEXT = new URI(CONTEXT);
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(
+ "Could not initialize LocalResourceManagerHelper due to URISyntaxException.", e);
+ }
+ }
+
+ // see https://cloud.google.com/resource-manager/reference/rest/v1beta1/projects
+ private static final Set PERMISSIBLE_PROJECT_NAME_PUNCTUATION =
+ ImmutableSet.of('-', '\'', '"', ' ', '!');
+
+ private HttpServer server;
+ private final ConcurrentHashMap projects = new ConcurrentHashMap<>();
+ private final int port;
+
+ private static class Response {
+ private final int code;
+ private final String body;
+
+ Response(int code, String body) {
+ this.code = code;
+ this.body = body;
+ }
+
+ int code() {
+ return code;
+ }
+
+ String body() {
+ return body;
+ }
+ }
+
+ private enum Error {
+ ALREADY_EXISTS(409, "global", "alreadyExists", "ALREADY_EXISTS"),
+ PERMISSION_DENIED(403, "global", "forbidden", "PERMISSION_DENIED"),
+ // change failed precondition error code to 412 when #440 is fixed
+ FAILED_PRECONDITION(400, "global", "failedPrecondition", "FAILED_PRECONDITION"),
+ // change invalid argument error code to 412 when #440 is fixed
+ INVALID_ARGUMENT(400, "global", "badRequest", "INVALID_ARGUMENT"),
+ BAD_REQUEST(400, "global", "badRequest", "BAD_REQUEST"),
+ INTERNAL_ERROR(500, "global", "internalError", "INTERNAL_ERROR");
+
+ private final int code;
+ private final String domain;
+ private final String reason;
+ private final String status;
+
+ Error(int code, String domain, String reason, String status) {
+ this.code = code;
+ this.domain = domain;
+ this.reason = reason;
+ this.status = status;
+ }
+
+ Response response(String message) {
+ try {
+ return new Response(code, toJson(message));
+ } catch (IOException e) {
+ return Error.INTERNAL_ERROR.response("Error when generating JSON error response");
+ }
+ }
+
+ private String toJson(String message) throws IOException {
+ Map errors = new HashMap<>();
+ errors.put("domain", domain);
+ errors.put("message", message);
+ errors.put("reason", reason);
+ Map args = new HashMap<>();
+ args.put("errors", ImmutableList.of(errors));
+ args.put("code", code);
+ args.put("message", message);
+ args.put("status", status);
+ return jsonFactory.toString(ImmutableMap.of("error", args));
+ }
+ }
+
+ private class RequestHandler implements HttpHandler {
+ @Override
+ public void handle(HttpExchange exchange) {
+ // see https://cloud.google.com/resource-manager/reference/rest/
+ Response response;
+ String path = BASE_CONTEXT.relativize(exchange.getRequestURI()).getPath();
+ String requestMethod = exchange.getRequestMethod();
+ try {
+ switch (requestMethod) {
+ case "POST":
+ if (path.endsWith(":undelete")) {
+ response = undelete(projectIdFromUri(path));
+ } else {
+ String requestBody =
+ decodeContent(exchange.getRequestHeaders(), exchange.getRequestBody());
+ response = create(jsonFactory.fromString(requestBody, Project.class));
+ }
+ break;
+ case "DELETE":
+ response = delete(projectIdFromUri(path));
+ break;
+ case "GET":
+ if (!path.isEmpty()) {
+ response =
+ get(projectIdFromUri(path), parseFields(exchange.getRequestURI().getQuery()));
+ } else {
+ response = list(parseListOptions(exchange.getRequestURI().getQuery()));
+ }
+ break;
+ case "PUT":
+ String requestBody =
+ decodeContent(exchange.getRequestHeaders(), exchange.getRequestBody());
+ response =
+ replace(projectIdFromUri(path), jsonFactory.fromString(requestBody, Project.class));
+ break;
+ default:
+ response = Error.BAD_REQUEST.response(
+ "The server could not understand the following request URI: " + requestMethod + " "
+ + path);
+ }
+ } catch (IOException e) {
+ response = Error.BAD_REQUEST.response(e.getMessage());
+ }
+ writeResponse(exchange, response);
+ }
+ }
+
+ private static void writeResponse(HttpExchange exchange, Response response) {
+ exchange.getResponseHeaders().set("Content-type", "application/json; charset=UTF-8");
+ OutputStream outputStream = exchange.getResponseBody();
+ try {
+ exchange.sendResponseHeaders(response.code(), response.body().length());
+ outputStream.write(response.body().getBytes(StandardCharsets.UTF_8));
+ outputStream.close();
+ } catch (IOException e) {
+ log.log(Level.WARNING, "IOException encountered when sending response.", e);
+ }
+ }
+
+ private static String decodeContent(Headers headers, InputStream inputStream) throws IOException {
+ List contentEncoding = headers.get("Content-encoding");
+ InputStream input = inputStream;
+ try {
+ if (contentEncoding != null && !contentEncoding.isEmpty()) {
+ String encoding = contentEncoding.get(0);
+ if (SUPPORTED_COMPRESSION_ENCODINGS.contains(encoding)) {
+ input = new GZIPInputStream(inputStream);
+ } else if (!encoding.equals("identity")) {
+ throw new IOException(
+ "The request has the following unsupported HTTP content encoding: " + encoding);
+ }
+ }
+ return new String(ByteStreams.toByteArray(input), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IOException("Exception encountered when decoding request content.", e);
+ }
+ }
+
+ private static String projectIdFromUri(String path) throws IOException {
+ if (path.isEmpty()) {
+ throw new IOException("The URI path '" + path + "' doesn't have a project ID.");
+ }
+ return path.split(":")[0];
+ }
+
+ private static String[] parseFields(String query) {
+ if (query != null && !query.isEmpty()) {
+ String[] querySplit = query.split("=");
+ return querySplit.length > 1 ? querySplit[1].split(",") : null;
+ }
+ return null;
+ }
+
+ private static Map parseListOptions(String query) {
+ Map options = new HashMap<>();
+ if (query != null) {
+ String[] args = query.split("&");
+ for (String arg : args) {
+ String[] argEntry = arg.split("=");
+ switch (argEntry[0]) {
+ case "fields":
+ options.put("fields", argEntry[1].split(","));
+ break;
+ case "filter":
+ options.put("filter", argEntry[1].split(" "));
+ break;
+ case "pageToken":
+ // support pageToken when Cloud Resource Manager supports this (#421)
+ break;
+ case "pageSize":
+ // support pageSize when Cloud Resource Manager supports this (#421)
+ break;
+ }
+ }
+ }
+ return options;
+ }
+
+ private static final String checkForProjectErrors(Project project) {
+ if (project.getProjectId() == null) {
+ return "Project ID cannot be empty.";
+ }
+ if (!isValidIdOrLabel(project.getProjectId(), 6, 30)) {
+ return "Project " + project.getProjectId() + " has an invalid ID."
+ + " See https://cloud.google.com/resource-manager/reference/rest/" + VERSION + "/projects"
+ + " for more information.";
+ }
+ if (project.getName() != null) {
+ for (char c : project.getName().toCharArray()) {
+ if (!PERMISSIBLE_PROJECT_NAME_PUNCTUATION.contains(c) && !Character.isLetterOrDigit(c)) {
+ return "Project " + project.getProjectId() + " has an invalid name."
+ + " See https://cloud.google.com/resource-manager/reference/rest/" + VERSION
+ + "/projects for more information.";
+ }
+ }
+ }
+ if (project.getLabels() != null) {
+ if (project.getLabels().size() > 256) {
+ return "Project " + project.getProjectId() + " exceeds the limit of 256 labels.";
+ }
+ for (Map.Entry entry : project.getLabels().entrySet()) {
+ if (!isValidIdOrLabel(entry.getKey(), 1, 63)
+ || !isValidIdOrLabel(entry.getValue(), 0, 63)) {
+ return "Project " + project.getProjectId() + " has an invalid label entry."
+ + " See https://cloud.google.com/resource-manager/reference/rest/" + VERSION
+ + "/projects for more information.";
+ }
+ }
+ }
+ return null;
+ }
+
+ private static final boolean isValidIdOrLabel(String value, int minLength, int maxLength) {
+ for (char c : value.toCharArray()) {
+ if (c != '-' && !Character.isDigit(c) && (!Character.isLowerCase(c))) {
+ return false;
+ }
+ }
+ if (!value.isEmpty() && (!Character.isLetter(value.charAt(0)) || value.endsWith("-"))) {
+ return false;
+ }
+ return value.length() >= minLength && value.length() <= maxLength;
+ }
+
+ Response create(Project project) {
+ String customErrorMessage = checkForProjectErrors(project);
+ if (customErrorMessage != null) {
+ return Error.INVALID_ARGUMENT.response(customErrorMessage);
+ } else {
+ project.setLifecycleState("ACTIVE");
+ project.setProjectNumber(Math.abs(PROJECT_NUMBER_GENERATOR.nextLong() % Long.MAX_VALUE));
+ project.setCreateTime(ISODateTimeFormat.dateTime().print(System.currentTimeMillis()));
+ if (projects.putIfAbsent(project.getProjectId(), project) != null) {
+ return Error.ALREADY_EXISTS.response(
+ "A project with the same project ID (" + project.getProjectId() + ") already exists.");
+ }
+ try {
+ String createdProjectStr = jsonFactory.toString(project);
+ return new Response(HTTP_OK, createdProjectStr);
+ } catch (IOException e) {
+ return Error.INTERNAL_ERROR.response("Error serializing project " + project.getProjectId());
+ }
+ }
+ }
+
+ Response delete(String projectId) {
+ Project project = projects.get(projectId);
+ if (project == null) {
+ // Currently the service returns 403 Permission Denied when trying to delete a project that
+ // doesn't exist. Here we mimic this behavior, but this line should be changed to throw a
+ // 404 Not Found error when the service fixes this (#440).
+ return Error.PERMISSION_DENIED.response(
+ "Error when deleting " + projectId + " because the project was not found.");
+ }
+ if (!project.getLifecycleState().equals("ACTIVE")) {
+ return Error.FAILED_PRECONDITION.response(
+ "Error when deleting " + projectId + " because the lifecycle state was not ACTIVE.");
+ } else {
+ project.setLifecycleState("DELETE_REQUESTED");
+ return new Response(HTTP_OK, "{}");
+ }
+ }
+
+ Response get(String projectId, String[] fields) {
+ if (!projects.containsKey(projectId)) {
+ // Currently the service returns 403 Permission Denied when trying to get a project that
+ // doesn't exist. Here we mimic this behavior, but this line should be changed to throw a
+ // 404 Not Found error when the service fixes this (#440).
+ return Error.PERMISSION_DENIED.response("Project " + projectId + " not found.");
+ }
+ Project project = projects.get(projectId);
+ try {
+ return new Response(HTTP_OK, jsonFactory.toString(extractFields(project, fields)));
+ } catch (IOException e) {
+ return Error.INTERNAL_ERROR.response(
+ "Error when serializing project " + project.getProjectId());
+ }
+ }
+
+ Response list(Map options) {
+ // Use pageSize and pageToken options when Cloud Resource Manager does so (#421)
+ List projectsSerialized = new ArrayList<>();
+ String[] filters = (String[]) options.get("filter");
+ if (filters != null && !isValidFilter(filters)) {
+ return Error.INVALID_ARGUMENT.response("Could not parse the filter.");
+ }
+ String[] fields = (String[]) options.get("fields");
+ for (Project p : projects.values()) {
+ Boolean includeProject = includeProject(p, filters);
+ if (includeProject) {
+ try {
+ projectsSerialized.add(jsonFactory.toString(extractFields(p, fields)));
+ } catch (IOException e) {
+ return Error.INTERNAL_ERROR.response(
+ "Error when serializing project " + p.getProjectId());
+ }
+ }
+ }
+ StringBuilder responseBody = new StringBuilder();
+ responseBody.append("{\"projects\": [");
+ Joiner.on(",").appendTo(responseBody, projectsSerialized);
+ responseBody.append("]}");
+ return new Response(HTTP_OK, responseBody.toString());
+ }
+
+ private static boolean isValidFilter(String[] filters) {
+ for (String filter : filters) {
+ String field = filter.toLowerCase().split(":")[0];
+ if (!("id".equals(field) || "name".equals(field) || field.startsWith("labels."))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean includeProject(Project project, String[] filters) {
+ if (filters == null) {
+ return true;
+ }
+ for (String filter : filters) {
+ String[] filterEntry = filter.toLowerCase().split(":");
+ String filterType = filterEntry[0];
+ if ("id".equals(filterType)) {
+ if (!satisfiesFilter(project.getProjectId(), filterEntry[1])) {
+ return false;
+ }
+ } else if ("name".equals(filterType)) {
+ if (!satisfiesFilter(project.getName(), filterEntry[1])) {
+ return false;
+ }
+ } else if (filterType.startsWith("labels.")) {
+ String labelKey = filterType.substring("labels.".length());
+ if (project.getLabels() != null) {
+ String labelValue = project.getLabels().get(labelKey);
+ if (!satisfiesFilter(labelValue, filterEntry[1])) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ private static boolean satisfiesFilter(String projectValue, String filterValue) {
+ if (projectValue == null) {
+ return false;
+ }
+ return "*".equals(filterValue) || filterValue.equals(projectValue.toLowerCase());
+ }
+
+ private static Project extractFields(Project fullProject, String[] fields) {
+ if (fields == null) {
+ return fullProject;
+ }
+ Project project = new Project();
+ for (String field : fields) {
+ switch (field) {
+ case "createTime":
+ project.setCreateTime(fullProject.getCreateTime());
+ break;
+ case "labels":
+ project.setLabels(fullProject.getLabels());
+ break;
+ case "lifecycleState":
+ project.setLifecycleState(fullProject.getLifecycleState());
+ break;
+ case "name":
+ project.setName(fullProject.getName());
+ break;
+ case "parent":
+ project.setParent(fullProject.getParent());
+ break;
+ case "projectId":
+ project.setProjectId(fullProject.getProjectId());
+ break;
+ case "projectNumber":
+ project.setProjectNumber(fullProject.getProjectNumber());
+ break;
+ }
+ }
+ return project;
+ }
+
+ Response replace(String projectId, Project project) {
+ Project originalProject = projects.get(projectId);
+ if (originalProject == null) {
+ // Currently the service returns 403 Permission Denied when trying to replace a project that
+ // doesn't exist. Here we mimic this behavior, but this line should be changed to throw a
+ // 404 Not Found error when the service fixes this (#440).
+ return Error.PERMISSION_DENIED.response(
+ "Error when replacing " + projectId + " because the project was not found.");
+ } else if (!originalProject.getLifecycleState().equals("ACTIVE")) {
+ return Error.FAILED_PRECONDITION.response(
+ "Error when replacing " + projectId + " because the lifecycle state was not ACTIVE.");
+ } else if (!Objects.equal(originalProject.getParent(), project.getParent())) {
+ return Error.INVALID_ARGUMENT.response(
+ "The server currently only supports setting the parent once "
+ + "and does not allow unsetting it.");
+ }
+ originalProject.setName(project.getName());
+ originalProject.setLabels(project.getLabels());
+ originalProject.setParent(project.getParent());
+ try {
+ return new Response(HTTP_OK, jsonFactory.toString(originalProject));
+ } catch (IOException e) {
+ return Error.INTERNAL_ERROR.response("Error when serializing project " + projectId);
+ }
+ }
+
+ Response undelete(String projectId) {
+ Project project = projects.get(projectId);
+ Response response;
+ if (project == null) {
+ // Currently the service returns 403 Permission Denied when trying to undelete a project that
+ // doesn't exist. Here we mimic this behavior, but this line should be changed to throw a
+ // 404 Not Found error when the service fixes this (#440).
+ response = Error.PERMISSION_DENIED.response(
+ "Error when undeleting " + projectId + " because the project was not found.");
+ } else if (!project.getLifecycleState().equals("DELETE_REQUESTED")) {
+ response = Error.FAILED_PRECONDITION.response("Error when undeleting " + projectId
+ + " because the lifecycle state was not DELETE_REQUESTED.");
+ } else {
+ project.setLifecycleState("ACTIVE");
+ response = new Response(HTTP_OK, "{}");
+ }
+ return response;
+ }
+
+ private LocalResourceManagerHelper() {
+ try {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ port = server.getAddress().getPort();
+ server.createContext(CONTEXT, new RequestHandler());
+ } catch (IOException e) {
+ throw new RuntimeException("Could not bind the mock Resource Manager server.", e);
+ }
+ }
+
+ /**
+ * Creates a LocalResourceManagerHelper object that listens to requests on the local machine.
+ */
+ public static LocalResourceManagerHelper create() {
+ return new LocalResourceManagerHelper();
+ }
+
+ /**
+ * Returns the port that the LocalResourceManagerHelper listens to for requests.
+ */
+ public int port() {
+ return port;
+ }
+
+ /**
+ * Starts the thread that runs the Resource Manager server.
+ */
+ public void start() {
+ server.start();
+ }
+
+ /**
+ * Stops the thread that runs the mock Resource Manager server.
+ */
+ public void stop() {
+ server.stop(1);
+ }
+
+ /**
+ * Utility method to change the lifecycle state of the specified project.
+ *
+ * @return true if the lifecycle state was successfully updated, false otherwise.
+ */
+ public boolean changeLifecycleState(String projectId, String lifecycleState) {
+ checkArgument(
+ "ACTIVE".equals(lifecycleState) || "DELETE_REQUESTED".equals(lifecycleState)
+ || "DELETE_IN_PROGRESS".equals(lifecycleState),
+ "Lifecycle state must be ACTIVE, DELETE_REQUESTED, or DELETE_IN_PROGRESS");
+ Project project = projects.get(checkNotNull(projectId));
+ if (project != null) {
+ project.setLifecycleState(lifecycleState);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Utility method to remove the specified project.
+ *
+ * This method can be used to fully remove a project (to mimic when the server completely
+ * deletes a project).
+ *
+ * @return true if the project was successfully deleted, false if the project didn't exist.
+ */
+ public boolean removeProject(String projectId) {
+ return projects.remove(checkNotNull(projectId)) != null;
+ }
+}
diff --git a/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/package-info.java b/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/package-info.java
new file mode 100644
index 000000000000..a2c07904ddbd
--- /dev/null
+++ b/gcloud-java-resourcemanager/src/main/java/com/google/gcloud/resourcemanager/testing/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2015 Google Inc. 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.
+ */
+
+/**
+ * A testing helper for Google Cloud Resource Manager.
+ *
+ *
A simple usage example:
+ *
Before the test:
+ *
{@code
+ * LocalResourceManagerHelper resourceManagerHelper = LocalResourceManagerHelper.create();
+ * ResourceManager resourceManager = resourceManagerHelper.options().service();
+ * }
+ *
+ * After the test:
+ *
{@code
+ * resourceManagerHelper.stop();
+ * }
+ */
+package com.google.gcloud.resourcemanager.testing;
diff --git a/gcloud-java-resourcemanager/src/test/java/com/google/gcloud/resourcemanager/LocalResourceManagerHelperTest.java b/gcloud-java-resourcemanager/src/test/java/com/google/gcloud/resourcemanager/LocalResourceManagerHelperTest.java
new file mode 100644
index 000000000000..785aa88d49fb
--- /dev/null
+++ b/gcloud-java-resourcemanager/src/test/java/com/google/gcloud/resourcemanager/LocalResourceManagerHelperTest.java
@@ -0,0 +1,550 @@
+package com.google.gcloud.resourcemanager;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gcloud.resourcemanager.testing.LocalResourceManagerHelper;
+import com.google.gcloud.spi.DefaultResourceManagerRpc;
+import com.google.gcloud.spi.ResourceManagerRpc;
+import com.google.gcloud.spi.ResourceManagerRpc.Tuple;
+
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+public class LocalResourceManagerHelperTest {
+
+ private static final String DEFAULT_PARENT_ID = "12345";
+ private static final String DEFAULT_PARENT_TYPE = "organization";
+ private static final com.google.api.services.cloudresourcemanager.model.ResourceId PARENT =
+ new com.google.api.services.cloudresourcemanager.model.ResourceId()
+ .setId(DEFAULT_PARENT_ID)
+ .setType(DEFAULT_PARENT_TYPE);
+ private static final Map EMPTY_RPC_OPTIONS = ImmutableMap.of();
+ private static final LocalResourceManagerHelper RESOURCE_MANAGER_HELPER =
+ LocalResourceManagerHelper.create();
+ private static final ResourceManagerRpc rpc = new DefaultResourceManagerRpc(
+ ResourceManagerOptions.builder()
+ .host("http://localhost:" + RESOURCE_MANAGER_HELPER.port())
+ .build());
+ private static final com.google.api.services.cloudresourcemanager.model.Project PARTIAL_PROJECT =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ "partial-project");
+ private static final com.google.api.services.cloudresourcemanager.model.Project COMPLETE_PROJECT =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId("complete-project")
+ .setName("full project")
+ .setLabels(ImmutableMap.of("k1", "v1", "k2", "v2"));
+ private static final com.google.api.services.cloudresourcemanager.model.Project
+ PROJECT_WITH_PARENT =
+ copyFrom(COMPLETE_PROJECT).setProjectId("project-with-parent-id").setParent(PARENT);
+
+ @BeforeClass
+ public static void beforeClass() {
+ RESOURCE_MANAGER_HELPER.start();
+ }
+
+ private static com.google.api.services.cloudresourcemanager.model.Project copyFrom(
+ com.google.api.services.cloudresourcemanager.model.Project from) {
+ return new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId(from.getProjectId())
+ .setName(from.getName())
+ .setLabels(from.getLabels() != null ? ImmutableMap.copyOf(from.getLabels()) : null)
+ .setProjectNumber(
+ from.getProjectNumber() != null ? from.getProjectNumber().longValue() : null)
+ .setCreateTime(from.getCreateTime())
+ .setLifecycleState(from.getLifecycleState())
+ .setParent(from.getParent() != null ? from.getParent().clone() : null);
+ }
+
+ private void clearProjects() {
+ for (com.google.api.services.cloudresourcemanager.model.Project project :
+ rpc.list(EMPTY_RPC_OPTIONS).y()) {
+ RESOURCE_MANAGER_HELPER.removeProject(project.getProjectId());
+ }
+ }
+
+ @Before
+ public void setUp() {
+ clearProjects();
+ }
+
+ @AfterClass
+ public static void afterClass() {
+ RESOURCE_MANAGER_HELPER.stop();
+ }
+
+ @Test
+ public void testCreate() {
+ com.google.api.services.cloudresourcemanager.model.Project returnedProject =
+ rpc.create(PARTIAL_PROJECT);
+ compareReadWriteFields(PARTIAL_PROJECT, returnedProject);
+ assertEquals("ACTIVE", returnedProject.getLifecycleState());
+ assertNull(returnedProject.getLabels());
+ assertNull(returnedProject.getName());
+ assertNull(returnedProject.getParent());
+ assertNotNull(returnedProject.getProjectNumber());
+ assertNotNull(returnedProject.getCreateTime());
+ try {
+ rpc.create(PARTIAL_PROJECT);
+ fail("Should fail, project already exists.");
+ } catch (ResourceManagerException e) {
+ assertEquals(409, e.code());
+ assertTrue(e.getMessage().startsWith("A project with the same project ID")
+ && e.getMessage().endsWith("already exists."));
+ }
+ returnedProject = rpc.create(PROJECT_WITH_PARENT);
+ compareReadWriteFields(PROJECT_WITH_PARENT, returnedProject);
+ assertEquals("ACTIVE", returnedProject.getLifecycleState());
+ assertNotNull(returnedProject.getProjectNumber());
+ assertNotNull(returnedProject.getCreateTime());
+ }
+
+ @Test
+ public void testIsInvalidProjectId() {
+ com.google.api.services.cloudresourcemanager.model.Project project =
+ new com.google.api.services.cloudresourcemanager.model.Project();
+ String invalidIDMessageSubstring = "invalid ID";
+ expectInvalidArgumentException(project, "Project ID cannot be empty.");
+ project.setProjectId("abcde");
+ expectInvalidArgumentException(project, invalidIDMessageSubstring);
+ project.setProjectId("this-project-id-is-more-than-thirty-characters-long");
+ expectInvalidArgumentException(project, invalidIDMessageSubstring);
+ project.setProjectId("project-id-with-invalid-character-?");
+ expectInvalidArgumentException(project, invalidIDMessageSubstring);
+ project.setProjectId("-invalid-start-character");
+ expectInvalidArgumentException(project, invalidIDMessageSubstring);
+ project.setProjectId("invalid-ending-character-");
+ expectInvalidArgumentException(project, invalidIDMessageSubstring);
+ project.setProjectId("some-valid-project-id-12345");
+ rpc.create(project);
+ assertNotNull(rpc.get(project.getProjectId(), EMPTY_RPC_OPTIONS));
+ }
+
+ private void expectInvalidArgumentException(
+ com.google.api.services.cloudresourcemanager.model.Project project,
+ String errorMessageSubstring) {
+ try {
+ rpc.create(project);
+ fail("Should fail because of an invalid argument.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains(errorMessageSubstring));
+ }
+ }
+
+ @Test
+ public void testIsInvalidProjectName() {
+ com.google.api.services.cloudresourcemanager.model.Project project =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ "some-project-id");
+ rpc.create(project);
+ assertNull(rpc.get(project.getProjectId(), EMPTY_RPC_OPTIONS).getName());
+ RESOURCE_MANAGER_HELPER.removeProject(project.getProjectId());
+ project.setName("This is a valid name-'\"!");
+ rpc.create(project);
+ assertEquals(project.getName(), rpc.get(project.getProjectId(), EMPTY_RPC_OPTIONS).getName());
+ RESOURCE_MANAGER_HELPER.removeProject(project.getProjectId());
+ project.setName("invalid-character-,");
+ try {
+ rpc.create(project);
+ fail("Should fail because of invalid project name.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("invalid name"));
+ }
+ }
+
+ @Test
+ public void testIsInvalidProjectLabels() {
+ com.google.api.services.cloudresourcemanager.model.Project project =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ "some-valid-project-id");
+ String invalidLabelMessageSubstring = "invalid label entry";
+ project.setLabels(ImmutableMap.of("", "v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of(
+ "this-project-label-is-more-than-sixty-three-characters-long-so-it-should-fail", "v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of(
+ "k1", "this-project-label-is-more-than-sixty-three-characters-long-so-it-should-fail"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("k1?", "v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("k1", "v1*"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("-k1", "v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("k1", "-v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("k1-", "v1"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ project.setLabels(ImmutableMap.of("k1", "v1-"));
+ expectInvalidArgumentException(project, invalidLabelMessageSubstring);
+ Map tooManyLabels = new HashMap<>();
+ for (int i = 0; i < 257; i++) {
+ tooManyLabels.put("k" + Integer.toString(i), "v" + Integer.toString(i));
+ }
+ project.setLabels(tooManyLabels);
+ expectInvalidArgumentException(project, "exceeds the limit of 256 labels");
+ project.setLabels(ImmutableMap.of("k-1", ""));
+ rpc.create(project);
+ assertNotNull(rpc.get(project.getProjectId(), EMPTY_RPC_OPTIONS));
+ assertTrue(rpc.get(project.getProjectId(), EMPTY_RPC_OPTIONS)
+ .getLabels()
+ .get("k-1")
+ .isEmpty());
+ }
+
+ @Test
+ public void testDelete() {
+ rpc.create(COMPLETE_PROJECT);
+ rpc.delete(COMPLETE_PROJECT.getProjectId());
+ assertEquals(
+ "DELETE_REQUESTED",
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS).getLifecycleState());
+ try {
+ rpc.delete("some-nonexistant-project-id");
+ fail("Should fail because the project doesn't exist.");
+ } catch (ResourceManagerException e) {
+ assertEquals(403, e.code());
+ assertTrue(e.getMessage().contains("not found."));
+ }
+ }
+
+ @Test
+ public void testDeleteWhenDeleteInProgress() {
+ rpc.create(COMPLETE_PROJECT);
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_IN_PROGRESS");
+ try {
+ rpc.delete(COMPLETE_PROJECT.getProjectId());
+ fail("Should fail because the project is not ACTIVE.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("the lifecycle state was not ACTIVE"));
+ }
+ }
+
+ @Test
+ public void testDeleteWhenDeleteRequested() {
+ rpc.create(COMPLETE_PROJECT);
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_REQUESTED");
+ try {
+ rpc.delete(COMPLETE_PROJECT.getProjectId());
+ fail("Should fail because the project is not ACTIVE.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("the lifecycle state was not ACTIVE"));
+ }
+ }
+
+ @Test
+ public void testGet() {
+ rpc.create(COMPLETE_PROJECT);
+ com.google.api.services.cloudresourcemanager.model.Project returnedProject =
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS);
+ compareReadWriteFields(COMPLETE_PROJECT, returnedProject);
+ RESOURCE_MANAGER_HELPER.removeProject(COMPLETE_PROJECT.getProjectId());
+ try {
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS);
+ fail("Should fail because the project doesn't exist.");
+ } catch (ResourceManagerException e) {
+ assertEquals(403, e.code());
+ assertTrue(e.getMessage().contains("not found"));
+ }
+ }
+
+ @Test
+ public void testGetWithOptions() {
+ com.google.api.services.cloudresourcemanager.model.Project originalProject =
+ rpc.create(COMPLETE_PROJECT);
+ Map rpcOptions = new HashMap<>();
+ rpcOptions.put(ResourceManagerRpc.Option.FIELDS, "projectId,name,createTime");
+ com.google.api.services.cloudresourcemanager.model.Project returnedProject =
+ rpc.get(COMPLETE_PROJECT.getProjectId(), rpcOptions);
+ assertFalse(COMPLETE_PROJECT.equals(returnedProject));
+ assertEquals(COMPLETE_PROJECT.getProjectId(), returnedProject.getProjectId());
+ assertEquals(COMPLETE_PROJECT.getName(), returnedProject.getName());
+ assertEquals(originalProject.getCreateTime(), returnedProject.getCreateTime());
+ assertNull(returnedProject.getParent());
+ assertNull(returnedProject.getProjectNumber());
+ assertNull(returnedProject.getLifecycleState());
+ assertNull(returnedProject.getLabels());
+ }
+
+ @Test
+ public void testList() {
+ Tuple> projects =
+ rpc.list(EMPTY_RPC_OPTIONS);
+ assertNull(projects.x()); // change this when #421 is resolved
+ assertFalse(projects.y().iterator().hasNext());
+ rpc.create(COMPLETE_PROJECT);
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_REQUESTED");
+ rpc.create(PROJECT_WITH_PARENT);
+ projects = rpc.list(EMPTY_RPC_OPTIONS);
+ Iterator it =
+ projects.y().iterator();
+ compareReadWriteFields(COMPLETE_PROJECT, it.next());
+ compareReadWriteFields(PROJECT_WITH_PARENT, it.next());
+ }
+
+ @Test
+ public void testListFieldOptions() {
+ Map rpcOptions = new HashMap<>();
+ rpcOptions.put(ResourceManagerRpc.Option.FIELDS, "projectId,name,labels");
+ rpcOptions.put(ResourceManagerRpc.Option.PAGE_TOKEN, "somePageToken");
+ rpcOptions.put(ResourceManagerRpc.Option.PAGE_SIZE, 1);
+ rpc.create(PROJECT_WITH_PARENT);
+ Tuple> projects =
+ rpc.list(rpcOptions);
+ com.google.api.services.cloudresourcemanager.model.Project returnedProject =
+ projects.y().iterator().next();
+ assertFalse(PROJECT_WITH_PARENT.equals(returnedProject));
+ assertEquals(PROJECT_WITH_PARENT.getProjectId(), returnedProject.getProjectId());
+ assertEquals(PROJECT_WITH_PARENT.getName(), returnedProject.getName());
+ assertEquals(PROJECT_WITH_PARENT.getLabels(), returnedProject.getLabels());
+ assertNull(returnedProject.getParent());
+ assertNull(returnedProject.getProjectNumber());
+ assertNull(returnedProject.getLifecycleState());
+ assertNull(returnedProject.getCreateTime());
+ }
+
+ @Test
+ public void testListFilterOptions() {
+ Map rpcFilterOptions = new HashMap<>();
+ rpcFilterOptions.put(
+ ResourceManagerRpc.Option.FILTER, "id:* name:myProject labels.color:blue LABELS.SIZE:*");
+ com.google.api.services.cloudresourcemanager.model.Project matchingProject =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId("matching-project")
+ .setName("MyProject")
+ .setLabels(ImmutableMap.of("color", "blue", "size", "big"));
+ com.google.api.services.cloudresourcemanager.model.Project nonMatchingProject1 =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId("non-matching-project1")
+ .setName("myProject");
+ nonMatchingProject1.setLabels(ImmutableMap.of("color", "blue"));
+ com.google.api.services.cloudresourcemanager.model.Project nonMatchingProject2 =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId("non-matching-project2")
+ .setName("myProj")
+ .setLabels(ImmutableMap.of("color", "blue", "size", "big"));
+ com.google.api.services.cloudresourcemanager.model.Project nonMatchingProject3 =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ "non-matching-project3");
+ rpc.create(matchingProject);
+ rpc.create(nonMatchingProject1);
+ rpc.create(nonMatchingProject2);
+ rpc.create(nonMatchingProject3);
+ for (com.google.api.services.cloudresourcemanager.model.Project p :
+ rpc.list(rpcFilterOptions).y()) {
+ assertFalse(p.equals(nonMatchingProject1));
+ assertFalse(p.equals(nonMatchingProject2));
+ compareReadWriteFields(matchingProject, p);
+ }
+ }
+
+ @Test
+ public void testReplace() {
+ com.google.api.services.cloudresourcemanager.model.Project createdProject =
+ rpc.create(COMPLETE_PROJECT);
+ String newName = "new name";
+ Map newLabels = ImmutableMap.of("new k1", "new v1");
+ com.google.api.services.cloudresourcemanager.model.Project anotherCompleteProject =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId(COMPLETE_PROJECT.getProjectId())
+ .setName(newName)
+ .setLabels(newLabels)
+ .setProjectNumber(987654321L)
+ .setCreateTime("2000-01-01T00:00:00.001Z")
+ .setLifecycleState("DELETE_REQUESTED");
+ com.google.api.services.cloudresourcemanager.model.Project returnedProject =
+ rpc.replace(anotherCompleteProject);
+ compareReadWriteFields(anotherCompleteProject, returnedProject);
+ assertEquals(createdProject.getProjectNumber(), returnedProject.getProjectNumber());
+ assertEquals(createdProject.getCreateTime(), returnedProject.getCreateTime());
+ assertEquals(createdProject.getLifecycleState(), returnedProject.getLifecycleState());
+ com.google.api.services.cloudresourcemanager.model.Project nonexistantProject =
+ new com.google.api.services.cloudresourcemanager.model.Project();
+ nonexistantProject.setProjectId("some-project-id-that-does-not-exist");
+ try {
+ rpc.replace(nonexistantProject);
+ fail("Should fail because the project doesn't exist.");
+ } catch (ResourceManagerException e) {
+ assertEquals(403, e.code());
+ assertTrue(e.getMessage().contains("the project was not found"));
+ }
+ }
+
+ @Test
+ public void testReplaceWhenDeleteRequested() {
+ rpc.create(COMPLETE_PROJECT);
+ rpc.delete(COMPLETE_PROJECT.getProjectId());
+ com.google.api.services.cloudresourcemanager.model.Project anotherProject =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ COMPLETE_PROJECT.getProjectId());
+ try {
+ rpc.replace(anotherProject);
+ fail("Should fail because the project is not ACTIVE.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("the lifecycle state was not ACTIVE"));
+ }
+ }
+
+ @Test
+ public void testReplaceWhenDeleteInProgress() {
+ rpc.create(COMPLETE_PROJECT);
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_IN_PROGRESS");
+ com.google.api.services.cloudresourcemanager.model.Project anotherProject =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ COMPLETE_PROJECT.getProjectId());
+ try {
+ rpc.replace(anotherProject);
+ fail("Should fail because the project is not ACTIVE.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("the lifecycle state was not ACTIVE"));
+ }
+ }
+
+ @Test
+ public void testReplaceAddingParent() {
+ rpc.create(COMPLETE_PROJECT);
+ com.google.api.services.cloudresourcemanager.model.Project anotherProject =
+ new com.google.api.services.cloudresourcemanager.model.Project()
+ .setProjectId(COMPLETE_PROJECT.getProjectId())
+ .setParent(PARENT);
+ try {
+ rpc.replace(anotherProject);
+ fail("Should fail because the project's parent was modified after creation.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertEquals(
+ "The server currently only supports setting the parent once "
+ + "and does not allow unsetting it.",
+ e.getMessage());
+ }
+ }
+
+ @Test
+ public void testReplaceRemovingParent() {
+ rpc.create(PROJECT_WITH_PARENT);
+ com.google.api.services.cloudresourcemanager.model.Project anotherProject =
+ new com.google.api.services.cloudresourcemanager.model.Project().setProjectId(
+ PROJECT_WITH_PARENT.getProjectId());
+ try {
+ rpc.replace(anotherProject);
+ fail("Should fail because the project's parent was unset.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertEquals(
+ "The server currently only supports setting the parent once "
+ + "and does not allow unsetting it.",
+ e.getMessage());
+ }
+ }
+
+ @Test
+ public void testUndelete() {
+ rpc.create(COMPLETE_PROJECT);
+ rpc.delete(COMPLETE_PROJECT.getProjectId());
+ assertEquals(
+ "DELETE_REQUESTED",
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS).getLifecycleState());
+ rpc.undelete(COMPLETE_PROJECT.getProjectId());
+ com.google.api.services.cloudresourcemanager.model.Project revivedProject =
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS);
+ compareReadWriteFields(COMPLETE_PROJECT, revivedProject);
+ assertEquals("ACTIVE", revivedProject.getLifecycleState());
+ try {
+ rpc.undelete("invalid-project-id");
+ fail("Should fail because the project doesn't exist.");
+ } catch (ResourceManagerException e) {
+ assertEquals(403, e.code());
+ assertTrue(e.getMessage().contains("the project was not found"));
+ }
+ }
+
+ @Test
+ public void testUndeleteWhenActive() {
+ rpc.create(COMPLETE_PROJECT);
+ try {
+ rpc.undelete(COMPLETE_PROJECT.getProjectId());
+ fail("Should fail because the project is not deleted.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("lifecycle state was not DELETE_REQUESTED"));
+ }
+ }
+
+ @Test
+ public void testUndeleteWhenDeleteInProgress() {
+ rpc.create(COMPLETE_PROJECT);
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_IN_PROGRESS");
+ try {
+ rpc.undelete(COMPLETE_PROJECT.getProjectId());
+ fail("Should fail because the project is in the process of being deleted.");
+ } catch (ResourceManagerException e) {
+ assertEquals(400, e.code());
+ assertTrue(e.getMessage().contains("lifecycle state was not DELETE_REQUESTED"));
+ }
+ }
+
+ @Test
+ public void testChangeLifecycleStatus() {
+ assertFalse(RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_IN_PROGRESS"));
+ rpc.create(COMPLETE_PROJECT);
+ assertTrue(RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "DELETE_IN_PROGRESS"));
+ assertEquals(
+ "DELETE_IN_PROGRESS",
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS).getLifecycleState());
+ try {
+ RESOURCE_MANAGER_HELPER.changeLifecycleState(
+ COMPLETE_PROJECT.getProjectId(), "INVALID_STATE");
+ fail("Should fail because of an invalid lifecycle state");
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+ }
+
+ @Test
+ public void testRemoveProject() {
+ assertFalse(RESOURCE_MANAGER_HELPER.removeProject(COMPLETE_PROJECT.getProjectId()));
+ rpc.create(COMPLETE_PROJECT);
+ assertTrue(RESOURCE_MANAGER_HELPER.removeProject(COMPLETE_PROJECT.getProjectId()));
+ try {
+ rpc.get(COMPLETE_PROJECT.getProjectId(), EMPTY_RPC_OPTIONS);
+ fail("Project shouldn't exist.");
+ } catch (ResourceManagerException e) {
+ assertEquals(403, e.code());
+ assertTrue(e.getMessage().contains("not found."));
+ }
+ }
+
+ private void compareReadWriteFields(
+ com.google.api.services.cloudresourcemanager.model.Project expected,
+ com.google.api.services.cloudresourcemanager.model.Project actual) {
+ assertEquals(expected.getProjectId(), actual.getProjectId());
+ assertEquals(expected.getName(), actual.getName());
+ assertEquals(expected.getLabels(), actual.getLabels());
+ assertEquals(expected.getParent(), actual.getParent());
+ }
+}