/* * Copyright © 2014-2016 Cask Data, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package co.cask.cdap.data2.datafabric.dataset.service; import co.cask.cdap.api.dataset.DatasetDefinition; import co.cask.cdap.api.dataset.DatasetProperties; import co.cask.cdap.api.dataset.DatasetSpecification; import co.cask.cdap.api.dataset.table.Get; import co.cask.cdap.api.dataset.table.Put; import co.cask.cdap.api.dataset.table.Table; import co.cask.cdap.common.conf.Constants; import co.cask.cdap.data2.dataset2.lib.table.CoreDatasetsModule; import co.cask.cdap.data2.dataset2.module.lib.inmemory.InMemoryTableModule; import co.cask.cdap.data2.transaction.queue.QueueConstants; import co.cask.cdap.data2.transaction.queue.hbase.HBaseConsumerStateStore; import co.cask.cdap.data2.transaction.queue.hbase.HBaseQueueDatasetModule; import co.cask.cdap.proto.DatasetInstanceConfiguration; import co.cask.cdap.proto.DatasetMeta; import co.cask.cdap.proto.DatasetModuleMeta; import co.cask.cdap.proto.DatasetSpecificationSummary; import co.cask.cdap.proto.Id; import co.cask.common.http.HttpRequest; import co.cask.common.http.HttpRequests; import co.cask.common.http.HttpResponse; import co.cask.common.http.ObjectResponse; import co.cask.tephra.DefaultTransactionExecutor; import co.cask.tephra.TransactionAware; import co.cask.tephra.TransactionExecutor; import co.cask.tephra.inmemory.InMemoryTxSystemClient; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import org.apache.http.HttpStatus; import org.junit.Assert; import org.junit.Test; import java.io.IOException; import java.net.URL; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.annotation.Nullable; /** * Unit-test for {@link DatasetInstanceHandler} */ public class DatasetInstanceHandlerTest extends DatasetServiceTestBase { private static final Gson GSON = new Gson(); @Test public void testSystemDatasetNotInList() throws Exception { try { deployModule("default-table", InMemoryTableModule.class); deployModule(HBaseConsumerStateStore.class.getSimpleName(), HBaseQueueDatasetModule.class); // yes it's weird, you can create one a system dataset, but don't expect to see it in the get all request Assert.assertEquals(HttpStatus.SC_OK, createInstance(QueueConstants.STATE_STORE_NAME, HBaseConsumerStateStore.class.getSimpleName()) .getResponseCode()); // nothing has been created, modules and types list is empty Assert.assertTrue(getInstances().getResponseObject().isEmpty()); } finally { // cleanup deleteInstance(QueueConstants.STATE_STORE_NAME); Assert.assertEquals(HttpStatus.SC_OK, deleteModules().getResponseCode()); } } @Test public void testBasics() throws Exception { // nothing has been created, modules and types list is empty List<DatasetSpecificationSummary> instances = getInstances().getResponseObject(); // nothing in the beginning Assert.assertEquals(0, instances.size()); try { // create dataset instance with type that is not yet known to the system should fail DatasetProperties props = DatasetProperties.builder().add("prop1", "val1").build(); Assert.assertEquals(HttpStatus.SC_NOT_FOUND, createInstance("dataset1", "datasetType2", props).getResponseCode()); // deploy modules deployModule("module1", TestModule1.class); deployModule("module2", TestModule2.class); // create dataset instance String description = "test instance description"; HttpResponse response = createInstance("dataset1", "datasetType2", description, props); Assert.assertEquals(HttpStatus.SC_OK, response.getResponseCode()); // verify module cannot be deleted which type is used for the dataset int modulesBeforeDelete = getModules().getResponseObject().size(); Assert.assertEquals(HttpStatus.SC_CONFLICT, deleteModule("module2").getResponseCode()); Assert.assertEquals(HttpStatus.SC_CONFLICT, deleteModules().getResponseCode()); Assert.assertEquals(modulesBeforeDelete, getModules().getResponseObject().size()); // verify instance was created instances = getInstances().getResponseObject(); Assert.assertEquals(1, instances.size()); // verifying spec is same as expected DatasetSpecification dataset1Spec = createType2Spec("dataset1", "datasetType2", description, props); Assert.assertEquals(spec2Summary(dataset1Spec), instances.get(0)); // verify created instance info can be retrieved DatasetMeta datasetInfo = getInstanceObject("dataset1").getResponseObject(); Assert.assertEquals(dataset1Spec, datasetInfo.getSpec()); Assert.assertEquals(dataset1Spec.getType(), datasetInfo.getType().getName()); // type meta should have 2 modules that has to be loaded to create type's class // and in the order they must be loaded List<DatasetModuleMeta> modules = datasetInfo.getType().getModules(); Assert.assertEquals(2, modules.size()); DatasetTypeHandlerTest.verify(modules.get(0), "module1", TestModule1.class, ImmutableList.of("datasetType1"), Collections.<String>emptyList(), ImmutableList.of("module2")); DatasetTypeHandlerTest.verify(modules.get(1), "module2", TestModule2.class, ImmutableList.of("datasetType2"), ImmutableList.of("module1"), Collections.<String>emptyList()); // try to retrieve non-existed instance Assert.assertEquals(HttpStatus.SC_NOT_FOUND, getInstance("non-existing-dataset").getResponseCode()); // cannot create instance with same name again Assert.assertEquals(HttpStatus.SC_CONFLICT, createInstance("dataset1", "datasetType2", props).getResponseCode()); Assert.assertEquals(1, getInstances().getResponseObject().size()); // cannot delete non-existing dataset instance Assert.assertEquals(HttpStatus.SC_NOT_FOUND, deleteInstance("non-existing-dataset").getResponseCode()); Assert.assertEquals(1, getInstances().getResponseObject().size()); // verify creation of dataset instance with null properties Assert.assertEquals(HttpStatus.SC_OK, createInstance("nullPropertiesTable", "datasetType2").getResponseCode()); // since dataset instance description is not provided, we are using the description given by the dataset type DatasetSpecification nullPropertiesTableSpec = createType2Spec("nullPropertiesTable", "datasetType2", TestModule2.DESCRIPTION, DatasetProperties.EMPTY); DatasetSpecificationSummary actualSummary = getSummaryForInstance("nullPropertiesTable", getInstances().getResponseObject()); Assert.assertEquals(spec2Summary(nullPropertiesTableSpec), actualSummary); // delete dataset instance Assert.assertEquals(HttpStatus.SC_OK, deleteInstance("dataset1").getResponseCode()); Assert.assertEquals(HttpStatus.SC_OK, deleteInstance("nullPropertiesTable").getResponseCode()); Assert.assertEquals(0, getInstances().getResponseObject().size()); // create workflow local dataset instance DatasetProperties localDSProperties = DatasetProperties.builder().add("prop1", "val1") .add(Constants.AppFabric.WORKFLOW_LOCAL_DATASET_PROPERTY, "true").build(); Assert.assertEquals(HttpStatus.SC_OK, createInstance("localDSInstance", "datasetType2", localDSProperties).getResponseCode()); // getInstances call should still return 0 Assert.assertEquals(0, getInstances().getResponseObject().size()); Assert.assertEquals(HttpStatus.SC_OK, deleteInstance("localDSInstance").getResponseCode()); // delete dataset modules Assert.assertEquals(HttpStatus.SC_OK, deleteModule("module2").getResponseCode()); Assert.assertEquals(HttpStatus.SC_OK, deleteModule("module1").getResponseCode()); } finally { deleteInstance("dataset1"); deleteInstance("nullPropertiesTable"); deleteInstance("localDSInstance"); deleteModule("module2"); deleteModule("module1"); } } @Test public void testUpdateInstance() throws Exception { // nothing has been created, modules and types list is empty List<DatasetSpecificationSummary> instances = getInstances().getResponseObject(); // nothing in the beginning Assert.assertEquals(0, instances.size()); try { DatasetProperties props = DatasetProperties.builder().add("prop1", "val1").build(); // deploy modules deployModule("module1", TestModule1.class); deployModule("module2", TestModule2.class); // create dataset instance Assert.assertEquals(HttpStatus.SC_OK, createInstance("dataset1", "datasetType2", props).getResponseCode()); // verify instance was created instances = getInstances().getResponseObject(); Assert.assertEquals(1, instances.size()); DatasetMeta meta = getInstanceObject("dataset1").getResponseObject(); Map<String, String> storedOriginalProps = meta.getSpec().getOriginalProperties(); Assert.assertEquals(props.getProperties(), storedOriginalProps); Map<String, String> retrievedProps = getInstanceProperties("dataset1").getResponseObject(); Assert.assertEquals(props.getProperties(), retrievedProps); Map<String, String> newProps = ImmutableMap.of("prop2", "val2"); // update dataset instance Assert.assertEquals(HttpStatus.SC_OK, updateInstance("dataset1", newProps).getResponseCode()); meta = getInstanceObject("dataset1").getResponseObject(); Assert.assertEquals(newProps, meta.getSpec().getOriginalProperties()); Assert.assertEquals("val2", meta.getSpec().getProperty("prop2")); Assert.assertNull(meta.getSpec().getProperty("prop1")); retrievedProps = getInstanceProperties("dataset1").getResponseObject(); Assert.assertEquals(newProps, retrievedProps); } finally { // delete dataset instance Assert.assertEquals(HttpStatus.SC_OK, deleteInstance("dataset1").getResponseCode()); Assert.assertEquals(0, getInstances().getResponseObject().size()); // delete dataset modules Assert.assertEquals(HttpStatus.SC_OK, deleteModule("module2").getResponseCode()); Assert.assertEquals(HttpStatus.SC_OK, deleteModule("module1").getResponseCode()); } } @Test public void testCreateDelete() throws Exception { try { deployModule("default-table", InMemoryTableModule.class); deployModule("default-core", CoreDatasetsModule.class); // cannot create instance with same name again Assert.assertEquals(HttpStatus.SC_OK, createInstance("myTable1", "table", DatasetProperties.EMPTY).getResponseCode()); Assert.assertEquals(HttpStatus.SC_OK, createInstance("myTable2", "table", DatasetProperties.EMPTY).getResponseCode()); Assert.assertEquals(2, getInstances().getResponseObject().size()); // we want to verify that data is also gone, so we write smth to tables first final Table table1 = dsFramework.getDataset(Id.DatasetInstance.from(Id.Namespace.DEFAULT, "myTable1"), DatasetDefinition.NO_ARGUMENTS, null); final Table table2 = dsFramework.getDataset(Id.DatasetInstance.from(Id.Namespace.DEFAULT, "myTable2"), DatasetDefinition.NO_ARGUMENTS, null); Assert.assertNotNull(table1); Assert.assertNotNull(table2); TransactionExecutor txExecutor = new DefaultTransactionExecutor(new InMemoryTxSystemClient(txManager), ImmutableList.of((TransactionAware) table1, (TransactionAware) table2)); txExecutor.execute(new TransactionExecutor.Subroutine() { @Override public void apply() throws Exception { table1.put(new Put("key1", "col1", "val1")); table2.put(new Put("key2", "col2", "val2")); } }); // verify that we can read the data txExecutor.execute(new TransactionExecutor.Subroutine() { @Override public void apply() throws Exception { Assert.assertEquals("val1", table1.get(new Get("key1", "col1")).getString("col1")); Assert.assertEquals("val2", table2.get(new Get("key2", "col2")).getString("col2")); } }); // delete table, check that it is deleted, create again and verify that it is empty Assert.assertEquals(HttpStatus.SC_OK, deleteInstance("myTable1").getResponseCode()); ObjectResponse<List<DatasetSpecificationSummary>> instances = getInstances(); Assert.assertEquals(1, instances.getResponseObject().size()); Assert.assertEquals("myTable2", instances.getResponseObject().get(0).getName()); Assert.assertEquals(HttpStatus.SC_OK, createInstance("myTable1", "table", DatasetProperties.EMPTY).getResponseCode()); Assert.assertEquals(2, getInstances().getResponseObject().size()); // verify that table1 is empty. Note: it is ok for test purpose to re-use the table clients txExecutor.execute(new TransactionExecutor.Subroutine() { @Override public void apply() throws Exception { Assert.assertTrue(table1.get(new Get("key1", "col1")).isEmpty()); Assert.assertEquals("val2", table2.get(new Get("key2", "col2")).getString("col2")); // writing smth to table1 for subsequent test table1.put(new Put("key3", "col3", "val3")); } }); // delete all tables, check that they deleted, create again and verify that they are empty deleteInstances(); Assert.assertEquals(0, getInstances().getResponseObject().size()); Assert.assertEquals(HttpStatus.SC_OK, createInstance("myTable1", "table", DatasetProperties.EMPTY).getResponseCode()); Assert.assertEquals(HttpStatus.SC_OK, createInstance("myTable2", "table", DatasetProperties.EMPTY).getResponseCode()); Assert.assertEquals(2, getInstances().getResponseObject().size()); // verify that tables are empty. Note: it is ok for test purpose to re-use the table clients txExecutor.execute(new TransactionExecutor.Subroutine() { @Override public void apply() throws Exception { Assert.assertTrue(table1.get(new Get("key3", "col3")).isEmpty()); Assert.assertTrue(table2.get(new Get("key2", "col2")).isEmpty()); } }); } finally { // cleanup deleteInstances(); Assert.assertEquals(HttpStatus.SC_OK, deleteModules().getResponseCode()); } } @Test public void testNotFound() throws IOException { Id.Namespace nonExistent = Id.Namespace.from("nonexistent"); Id.DatasetInstance datasetInstance = Id.DatasetInstance.from(nonExistent, "ds"); HttpResponse response = makeInstancesRequest(nonExistent.getId()); assertNamespaceNotFound(response, nonExistent); // TODO: commented out for now until we add back namespace checks on get dataset CDAP-3901 // response = getInstance(datasetInstance); // assertNamespaceNotFound(response, nonExistent); response = createInstance(datasetInstance, Table.class.getName(), null); assertNamespaceNotFound(response, nonExistent); response = updateInstance(datasetInstance, new HashMap<String, String>()); assertNamespaceNotFound(response, nonExistent); response = deleteInstance(datasetInstance); assertNamespaceNotFound(response, nonExistent); // it should be ok to use system response = getInstances(Id.Namespace.SYSTEM.getId()); Assert.assertEquals(200, response.getResponseCode()); } private HttpResponse createInstance(String instanceName, String typeName, DatasetProperties props) throws IOException { return createInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName), typeName, props); } private HttpResponse createInstance(String instanceName, String typeName, String description, DatasetProperties props) throws IOException { return createInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName), typeName, description, props); } private HttpResponse createInstance(String instanceName, String typeName) throws IOException { return createInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName), typeName, null); } private HttpResponse createInstance(Id.DatasetInstance instance, String typeName, @Nullable DatasetProperties props) throws IOException { return createInstance(instance, typeName, null, props); } private HttpResponse createInstance(Id.DatasetInstance instance, String typeName, @Nullable String description, @Nullable DatasetProperties props) throws IOException { DatasetInstanceConfiguration creationProperties; if (props != null) { creationProperties = new DatasetInstanceConfiguration(typeName, props.getProperties(), description); } else { creationProperties = new DatasetInstanceConfiguration(typeName, null, description); } HttpRequest request = HttpRequest.put(getUrl(instance.getNamespaceId(), "/data/datasets/" + instance.getId())) .withBody(GSON.toJson(creationProperties)).build(); return HttpRequests.execute(request); } private HttpResponse updateInstance(String instanceName, Map<String, String> props) throws IOException { return updateInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName), props); } private HttpResponse updateInstance(Id.DatasetInstance instance, Map<String, String> props) throws IOException { HttpRequest request = HttpRequest.put(getUrl(instance.getNamespaceId(), "/data/datasets/" + instance.getId() + "/properties")) .withBody(GSON.toJson(props)).build(); return HttpRequests.execute(request); } private ObjectResponse<List<DatasetSpecificationSummary>> getInstances() throws IOException { return getInstances(Id.Namespace.DEFAULT.getId()); } private ObjectResponse<List<DatasetSpecificationSummary>> getInstances(String namespace) throws IOException { return ObjectResponse.fromJsonBody(makeInstancesRequest(namespace), new TypeToken<List<DatasetSpecificationSummary>>() { }.getType()); } private HttpResponse makeInstancesRequest(String namespace) throws IOException { HttpRequest request = HttpRequest.get(getUrl(namespace, "/data/datasets")).build(); return HttpRequests.execute(request); } private HttpResponse getInstance(String instanceName) throws IOException { return getInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName)); } private HttpResponse getInstance(Id.DatasetInstance instance) throws IOException { URL instanceUrl = getUrl(instance.getNamespaceId(), "/data/datasets/" + instance.getId()); HttpRequest request = HttpRequest.get(instanceUrl).build(); return HttpRequests.execute(request); } private ObjectResponse<DatasetMeta> getInstanceObject(String instanceName) throws IOException { HttpRequest request = HttpRequest.get(getUrl("/data/datasets/" + instanceName)).build(); HttpResponse response = HttpRequests.execute(request); return ObjectResponse.fromJsonBody(response, DatasetMeta.class); } private ObjectResponse<Map<String, String>> getInstanceProperties(String instanceName) throws IOException { HttpRequest request = HttpRequest.get(getUrl("/data/datasets/" + instanceName + "/properties")).build(); HttpResponse response = HttpRequests.execute(request); return ObjectResponse.fromJsonBody(response, new TypeToken<Map<String, String>>() { }.getType()); } private HttpResponse deleteInstance(String instanceName) throws IOException { return deleteInstance(Id.DatasetInstance.from(Id.Namespace.DEFAULT, instanceName)); } private HttpResponse deleteInstance(Id.DatasetInstance instance) throws IOException { HttpRequest request = HttpRequest.delete(getUrl(instance.getNamespaceId(), "/data/datasets/" + instance.getId())).build(); return HttpRequests.execute(request); } private void deleteInstances() throws IOException { // NOTE: intentionally, there's no endpoint to delete all datasets instances currently to prevent bad things // happen by accident for (DatasetSpecificationSummary spec : getInstances().getResponseObject()) { deleteInstance(spec.getName()); } } private static DatasetSpecification createType2Spec(String instanceName, String typeName, String description, DatasetProperties properties) { return new TestModule2().createDefinition(typeName).configure(instanceName, properties) .setOriginalProperties(properties).setDescription(description); } private DatasetSpecificationSummary spec2Summary(DatasetSpecification spec) { return new DatasetSpecificationSummary(spec.getName(), spec.getType(), spec.getDescription(), spec.getOriginalProperties()); } private DatasetSpecificationSummary getSummaryForInstance(String instanceName, List<DatasetSpecificationSummary> summaries) { for (DatasetSpecificationSummary summary : summaries) { if (instanceName.equals(summary.getName())) { return summary; } } return null; } @Test public void testErrorResponses() throws Exception { try { // we need at least one valid type to test deployModule("default-table", InMemoryTableModule.class); validateGet(getUrl("/data/datasets"), 200); validateGet(getUrl("nosuchnamespace", "/data/datasets"), 404); validateGet(getUrl("inval+d", "/data/datasets"), 400); validatePut(getUrl("/data/datasets"), "", 405); validatePost(getUrl("/data/datasets"), "", 405); validateDelete(getUrl("/data/datasets"), 405); validateGet(getUrl("/data/datasets/nusuch"), 404); validateGet(getUrl("/data/datasets/nusüch"), 400); validateGet(getUrl("nosuchnamespace", "/data/datasets/nusuch"), 404); validateGet(getUrl("nosuchnamespace", "/data/datasets/nusüch"), 400, 404); validateGet(getUrl("inval+d", "/data/datasets/nusuch"), 400); validatePost(getUrl("/data/datasets/nusuch"), "", 405); validatePost(getUrl("/data/datasets/nusüch"), "", 405); validatePost(getUrl("nosuchnamespace", "/data/datasets/nusuch"), "", 405); validatePost(getUrl("inval+d", "/data/datasets/nusuch"), "", 405); validateDelete(getUrl("/data/datasets/nusuch"), 404); validateDelete(getUrl("/data/datasets/nusüch"), 400); validateDelete(getUrl("nosuchnamespace", "/data/datasets/nusuch"), 404); validateDelete(getUrl("inval+d", "/data/datasets/nusuch"), 400); validatePut(getUrl("/data/datasets/xü"), "{'typeName':'table'}", 400); validatePut(getUrl("/data/datasets/x"), "{'typeName':'tablü'}", 400); validatePut(getUrl("/data/datasets/x"), "{'type':'table'}", 400); validatePut(getUrl("/data/datasets/x"), "{'type':'tab", 400); validatePut(getUrl("/data/datasets/x"), "", 400); validatePut(getUrl("nusuchns", "/data/datasets/xü"), "{'typeName':'table'}", 400, 404); validatePut(getUrl("nusuchns", "/data/datasets/x"), "{'typeName':'tablü'}", 400, 404); validatePut(getUrl("nusuchns", "/data/datasets/x"), "{'type':'table'}", 400, 404); validatePut(getUrl("nusuchns", "/data/datasets/x"), "{'type':'tab", 400, 404); validatePut(getUrl("nusuchns", "/data/datasets/x"), "", 400, 404); validatePut(getUrl("inval+d", "/data/datasets/xü"), "{'typeName':'table'}", 400); validatePut(getUrl("inval+d", "/data/datasets/x"), "{'typeName':'tablü'}", 400); validatePut(getUrl("inval+d", "/data/datasets/x"), "{'type':'table'}", 400); validatePut(getUrl("inval+d", "/data/datasets/x"), "{'type':'tab", 400); validatePut(getUrl("inval+d", "/data/datasets/x"), "", 400); // create one dataset to test PUT .../properties on it validatePut(getUrl("/data/datasets/x"), "{'typeName':'table'}", 200); // TODO (CDAP-4420): commented because netty-http throws "response already sent" for these and request times out // TODO: debug this in netty-http (this is not an issue with DatasetInstanceHandler, it never gets called) //validateGet(getUrl("/data/datasets/y/properties"), 405); //validatePost(getUrl("/data/datasets/x/properties"), "", 405); //validateDelete(getUrl("/data/datasets/x/properties"), 405); validatePut(getUrl("/data/datasets/y/properties"), "{'x':'y'}", 404); validatePut(getUrl("/data/datasets/y/properties"), "{'x':'}", 400, 404); validatePut(getUrl("/data/datasets/ü/properties"), "{'x':'y'}", 400); validatePut(getUrl("/data/datasets/ü/properties"), "{'x':'y", 400); validatePut(getUrl("nosuchns", "/data/datasets/x/properties"), "{}", 404); validatePut(getUrl("nosuchns", "/data/datasets/x/properties"), "{", 400, 404); validatePut(getUrl("inval+dns", "/data/datasets/x/properties"), "{'x':'y'}", 400); } finally { // cleanup deleteInstance("x"); Assert.assertEquals(HttpStatus.SC_OK, deleteModules().getResponseCode()); } } private void validateGet(URL url, Integer ... expected) throws IOException { HttpRequest request = HttpRequest.get(url).build(); assertStatus(HttpRequests.execute(request).getResponseCode(), url, expected); } private void validateDelete(URL url, Integer ... expected) throws IOException { HttpRequest request = HttpRequest.delete(url).build(); assertStatus(HttpRequests.execute(request).getResponseCode(), url, expected); } private void validatePut(URL url, String body, Integer ... expected) throws IOException { HttpRequest request = HttpRequest.put(url).withBody(body).build(); assertStatus(HttpRequests.execute(request).getResponseCode(), url, expected); } private void validatePost(URL url, String body, Integer ... expected) throws IOException { HttpRequest request = HttpRequest.post(url).withBody(body).build(); assertStatus(HttpRequests.execute(request).getResponseCode(), url, expected); } private void assertStatus(int status, URL url, Integer ... expected) { Assert.assertTrue(String.format("Expected %s for %s but got %d", Arrays.toString(expected), url, status), Arrays.asList(expected).contains(status)); } }