2 * Copyright (c) 2016 Cisco Systems, Inc. and others. All rights reserved.
4 * This program and the accompanying materials are made available under the
5 * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6 * and is available at http://www.eclipse.org/legal/epl-v10.html
8 package org.opendaylight.restconf.nb.rfc8040.rests.services.impl;
10 import static org.junit.jupiter.api.Assertions.assertEquals;
11 import static org.junit.jupiter.api.Assertions.assertFalse;
12 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
13 import static org.junit.jupiter.api.Assertions.assertNotNull;
14 import static org.junit.jupiter.api.Assertions.assertNull;
15 import static org.junit.jupiter.api.Assertions.assertThrows;
16 import static org.junit.jupiter.api.Assertions.assertTrue;
17 import static org.mockito.ArgumentMatchers.any;
18 import static org.mockito.Mockito.doNothing;
19 import static org.mockito.Mockito.doReturn;
20 import static org.mockito.Mockito.mock;
21 import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateFalseFluentFuture;
22 import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateFluentFuture;
23 import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateTrueFluentFuture;
25 import java.io.ByteArrayInputStream;
26 import java.io.InputStream;
28 import java.nio.charset.StandardCharsets;
29 import java.util.Collection;
30 import java.util.List;
31 import java.util.Optional;
33 import javax.ws.rs.container.AsyncResponse;
34 import javax.ws.rs.core.MultivaluedHashMap;
35 import javax.ws.rs.core.MultivaluedMap;
36 import javax.ws.rs.core.Response;
37 import javax.ws.rs.core.UriBuilder;
38 import javax.ws.rs.core.UriInfo;
39 import org.junit.Before;
40 import org.junit.Test;
41 import org.junit.runner.RunWith;
42 import org.mockito.ArgumentCaptor;
43 import org.mockito.Captor;
44 import org.mockito.Mock;
45 import org.mockito.junit.MockitoJUnitRunner;
46 import org.opendaylight.mdsal.common.api.CommitInfo;
47 import org.opendaylight.mdsal.common.api.LogicalDatastoreType;
48 import org.opendaylight.mdsal.dom.api.DOMActionService;
49 import org.opendaylight.mdsal.dom.api.DOMDataBroker;
50 import org.opendaylight.mdsal.dom.api.DOMDataTreeReadTransaction;
51 import org.opendaylight.mdsal.dom.api.DOMDataTreeReadWriteTransaction;
52 import org.opendaylight.mdsal.dom.api.DOMMountPoint;
53 import org.opendaylight.mdsal.dom.api.DOMMountPointService;
54 import org.opendaylight.mdsal.dom.api.DOMRpcService;
55 import org.opendaylight.mdsal.dom.api.DOMSchemaService;
56 import org.opendaylight.mdsal.dom.spi.FixedDOMSchemaService;
57 import org.opendaylight.netconf.dom.api.NetconfDataTreeService;
58 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
59 import org.opendaylight.restconf.common.patch.PatchContext;
60 import org.opendaylight.restconf.common.patch.PatchEntity;
61 import org.opendaylight.restconf.common.patch.PatchStatusContext;
62 import org.opendaylight.restconf.nb.rfc8040.AbstractJukeboxTest;
63 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindContext;
64 import org.opendaylight.restconf.nb.rfc8040.legacy.NormalizedNodePayload;
65 import org.opendaylight.restconf.nb.rfc8040.rests.services.api.RestconfStreamsSubscriptionService;
66 import org.opendaylight.restconf.nb.rfc8040.streams.StreamsConfiguration;
67 import org.opendaylight.restconf.nb.rfc8040.streams.listeners.ListenersBroker;
68 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.yang.patch.rev170222.yang.patch.yang.patch.Edit.Operation;
69 import org.opendaylight.yangtools.yang.common.ErrorTag;
70 import org.opendaylight.yangtools.yang.common.ErrorType;
71 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
72 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
73 import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
74 import org.opendaylight.yangtools.yang.data.api.schema.DataContainerChild;
75 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
76 import org.opendaylight.yangtools.yang.data.impl.schema.Builders;
77 import org.opendaylight.yangtools.yang.model.api.SchemaContext;
79 @RunWith(MockitoJUnitRunner.StrictStubs.class)
80 public class RestconfDataServiceImplTest extends AbstractJukeboxTest {
81 private static final NodeIdentifier PLAYLIST_NID = new NodeIdentifier(PLAYLIST_QNAME);
82 private static final NodeIdentifier LIBRARY_NID = new NodeIdentifier(LIBRARY_QNAME);
84 // config contains one child the same as in operational and one additional
85 private static final ContainerNode CONFIG_JUKEBOX = Builders.containerBuilder()
86 .withNodeIdentifier(new NodeIdentifier(JUKEBOX_QNAME))
87 .withChild(CONT_PLAYER)
88 .withChild(Builders.containerBuilder().withNodeIdentifier(LIBRARY_NID).build())
90 // operational contains one child the same as in config and one additional
91 private static final ContainerNode OPER_JUKEBOX = Builders.containerBuilder()
92 .withNodeIdentifier(new NodeIdentifier(JUKEBOX_QNAME))
93 .withChild(CONT_PLAYER)
94 .withChild(Builders.mapBuilder().withNodeIdentifier(PLAYLIST_NID).build())
98 private UriInfo uriInfo;
100 private DOMDataTreeReadWriteTransaction readWrite;
102 private DOMDataTreeReadTransaction read;
104 private DOMMountPointService mountPointService;
106 private DOMMountPoint mountPoint;
108 private DOMDataBroker mountDataBroker;
110 private NetconfDataTreeService netconfService;
112 private DOMActionService actionService;
114 private DOMRpcService rpcService;
116 private RestconfStreamsSubscriptionService delegRestconfSubscrService;
118 private MultivaluedMap<String, String> queryParamenters;
120 private AsyncResponse asyncResponse;
122 private ArgumentCaptor<Response> responseCaptor;
124 private RestconfDataServiceImpl dataService;
127 public void setUp() throws Exception {
128 doReturn(Set.of()).when(queryParamenters).entrySet();
129 doReturn(queryParamenters).when(uriInfo).getQueryParameters();
131 doReturn(CommitInfo.emptyFluentFuture()).when(readWrite).commit();
133 final var dataBroker = mock(DOMDataBroker.class);
134 doReturn(read).when(dataBroker).newReadOnlyTransaction();
135 doReturn(readWrite).when(dataBroker).newReadWriteTransaction();
137 dataService = new RestconfDataServiceImpl(() -> DatabindContext.ofModel(JUKEBOX_SCHEMA),
138 new MdsalRestconfServer(dataBroker, rpcService, mountPointService), dataBroker, delegRestconfSubscrService,
139 actionService, new ListenersBroker(), new StreamsConfiguration(0, 1, 0, false));
140 doReturn(Optional.of(mountPoint)).when(mountPointService)
141 .getMountPoint(any(YangInstanceIdentifier.class));
142 doReturn(Optional.of(FixedDOMSchemaService.of(JUKEBOX_SCHEMA))).when(mountPoint)
143 .getService(DOMSchemaService.class);
144 doReturn(Optional.of(mountDataBroker)).when(mountPoint).getService(DOMDataBroker.class);
145 doReturn(Optional.of(rpcService)).when(mountPoint).getService(DOMRpcService.class);
146 doReturn(Optional.empty()).when(mountPoint).getService(NetconfDataTreeService.class);
147 doReturn(read).when(mountDataBroker).newReadOnlyTransaction();
148 doReturn(readWrite).when(mountDataBroker).newReadWriteTransaction();
152 public void testReadData() {
153 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
154 doReturn(immediateFluentFuture(Optional.of(EMPTY_JUKEBOX))).when(read)
155 .read(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
156 doReturn(immediateFluentFuture(Optional.empty()))
157 .when(read).read(LogicalDatastoreType.OPERATIONAL, JUKEBOX_IID);
158 final Response response = dataService.readData("example-jukebox:jukebox", uriInfo);
159 assertNotNull(response);
160 assertEquals(200, response.getStatus());
161 assertEquals(EMPTY_JUKEBOX, ((NormalizedNodePayload) response.getEntity()).data());
165 public void testReadRootData() {
166 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
167 doReturn(immediateFluentFuture(Optional.of(wrapNodeByDataRootContainer(CONFIG_JUKEBOX))))
169 .read(LogicalDatastoreType.CONFIGURATION, YangInstanceIdentifier.of());
170 doReturn(immediateFluentFuture(Optional.of(wrapNodeByDataRootContainer(OPER_JUKEBOX))))
172 .read(LogicalDatastoreType.OPERATIONAL, YangInstanceIdentifier.of());
173 final Response response = dataService.readData(uriInfo);
174 assertNotNull(response);
175 assertEquals(200, response.getStatus());
177 final NormalizedNode data = ((NormalizedNodePayload) response.getEntity()).data();
178 assertTrue(data instanceof ContainerNode);
179 final Collection<DataContainerChild> rootNodes = ((ContainerNode) data).body();
180 assertEquals(1, rootNodes.size());
181 final Collection<DataContainerChild> allDataChildren = ((ContainerNode) rootNodes.iterator().next()).body();
182 assertEquals(3, allDataChildren.size());
185 private static ContainerNode wrapNodeByDataRootContainer(final DataContainerChild data) {
186 return Builders.containerBuilder()
187 .withNodeIdentifier(NodeIdentifier.create(SchemaContext.NAME))
193 * Test read data from mount point when both {@link LogicalDatastoreType#CONFIGURATION} and
194 * {@link LogicalDatastoreType#OPERATIONAL} contains the same data and some additional data to be merged.
197 public void testReadDataMountPoint() {
198 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
199 doReturn(immediateFluentFuture(Optional.of(CONFIG_JUKEBOX))).when(read)
200 .read(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
201 doReturn(immediateFluentFuture(Optional.of(OPER_JUKEBOX))).when(read)
202 .read(LogicalDatastoreType.OPERATIONAL, JUKEBOX_IID);
204 final Response response = dataService.readData(
205 "example-jukebox:jukebox/yang-ext:mount/example-jukebox:jukebox", uriInfo);
207 assertNotNull(response);
208 assertEquals(200, response.getStatus());
210 // response must contain all child nodes from config and operational containers merged in one container
211 final NormalizedNode data = ((NormalizedNodePayload) response.getEntity()).data();
212 assertTrue(data instanceof ContainerNode);
213 assertEquals(3, ((ContainerNode) data).size());
214 assertNotNull(((ContainerNode) data).childByArg(CONT_PLAYER.name()));
215 assertNotNull(((ContainerNode) data).childByArg(LIBRARY_NID));
216 assertNotNull(((ContainerNode) data).childByArg(PLAYLIST_NID));
220 public void testReadDataNoData() {
221 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
222 doReturn(immediateFluentFuture(Optional.empty()))
223 .when(read).read(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
224 doReturn(immediateFluentFuture(Optional.empty()))
225 .when(read).read(LogicalDatastoreType.OPERATIONAL, JUKEBOX_IID);
227 final var errors = assertThrows(RestconfDocumentedException.class,
228 () -> dataService.readData("example-jukebox:jukebox", uriInfo)).getErrors();
229 assertEquals(1, errors.size());
230 final var error = errors.get(0);
231 assertEquals(ErrorType.PROTOCOL, error.getErrorType());
232 assertEquals(ErrorTag.DATA_MISSING, error.getErrorTag());
233 assertEquals("Request could not be completed because the relevant data model content does not exist",
234 error.getErrorMessage());
238 * Read data from config datastore according to content parameter.
241 public void testReadDataConfigTest() {
242 final MultivaluedHashMap<String, String> parameters = new MultivaluedHashMap<>();
243 parameters.put("content", List.of("config"));
245 doReturn(parameters).when(uriInfo).getQueryParameters();
246 doReturn(immediateFluentFuture(Optional.of(CONFIG_JUKEBOX))).when(read)
247 .read(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
249 final Response response = dataService.readData("example-jukebox:jukebox", uriInfo);
251 assertNotNull(response);
252 assertEquals(200, response.getStatus());
254 // response must contain only config data
255 final NormalizedNode data = ((NormalizedNodePayload) response.getEntity()).data();
257 // config data present
258 assertNotNull(((ContainerNode) data).childByArg(CONT_PLAYER.name()));
259 assertNotNull(((ContainerNode) data).childByArg(LIBRARY_NID));
262 assertNull(((ContainerNode) data).childByArg(PLAYLIST_NID));
266 * Read data from operational datastore according to content parameter.
269 public void testReadDataOperationalTest() {
270 final MultivaluedHashMap<String, String> parameters = new MultivaluedHashMap<>();
271 parameters.put("content", List.of("nonconfig"));
273 doReturn(parameters).when(uriInfo).getQueryParameters();
274 doReturn(immediateFluentFuture(Optional.of(OPER_JUKEBOX))).when(read)
275 .read(LogicalDatastoreType.OPERATIONAL, JUKEBOX_IID);
277 final Response response = dataService.readData("example-jukebox:jukebox", uriInfo);
279 assertNotNull(response);
280 assertEquals(200, response.getStatus());
282 // response must contain only operational data
283 final NormalizedNode data = ((NormalizedNodePayload) response.getEntity()).data();
285 // state data present
286 assertNotNull(((ContainerNode) data).childByArg(CONT_PLAYER.name()));
287 assertNotNull(((ContainerNode) data).childByArg(PLAYLIST_NID));
289 // config data absent
290 assertNull(((ContainerNode) data).childByArg(LIBRARY_NID));
294 public void testPutData() {
295 doReturn(immediateTrueFluentFuture()).when(read)
296 .exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
297 doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
298 final var response = dataService.putDataJSON("example-jukebox:jukebox", uriInfo, stringInputStream("""
300 "example-jukebox:jukebox" : {
306 assertNotNull(response);
307 assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
311 public void testPutDataWithMountPoint() {
312 doReturn(immediateTrueFluentFuture()).when(read)
313 .exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
314 doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
315 final var response = dataService.putDataXML("example-jukebox:jukebox/yang-ext:mount/example-jukebox:jukebox",
316 uriInfo, stringInputStream("""
317 <jukebox xmlns="http://example.com/ns/example-jukebox">
322 assertNotNull(response);
323 assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
326 private static InputStream stringInputStream(final String str) {
327 return new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));
331 public void testPostData() {
332 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
333 doReturn(immediateFalseFluentFuture()).when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
334 doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID,
335 Builders.containerBuilder().withNodeIdentifier(new NodeIdentifier(JUKEBOX_QNAME)).build());
336 doReturn(UriBuilder.fromUri("http://localhost:8181/rests/")).when(uriInfo).getBaseUriBuilder();
338 final var captor = ArgumentCaptor.forClass(Response.class);
339 doReturn(true).when(asyncResponse).resume(captor.capture());
340 dataService.postDataJSON(stringInputStream("""
342 "example-jukebox:jukebox" : {
344 }"""), uriInfo, asyncResponse);
345 final var response = captor.getValue();
346 assertEquals(201, response.getStatus());
347 assertEquals(URI.create("http://localhost:8181/rests/data/example-jukebox:jukebox"), response.getLocation());
351 public void testPostMapEntryData() {
352 doReturn(new MultivaluedHashMap<>()).when(uriInfo).getQueryParameters();
353 final var node = PLAYLIST_IID.node(BAND_ENTRY.name());
354 doReturn(immediateFalseFluentFuture()).when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, node);
355 doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, node, BAND_ENTRY);
356 doReturn(UriBuilder.fromUri("http://localhost:8181/rests/")).when(uriInfo).getBaseUriBuilder();
358 final var captor = ArgumentCaptor.forClass(Response.class);
359 doReturn(true).when(asyncResponse).resume(captor.capture());
360 dataService.postDataJSON("example-jukebox:jukebox", stringInputStream("""
362 "example-jukebox:playlist" : {
363 "name" : "name of band",
364 "description" : "band description"
366 }"""), uriInfo, asyncResponse);
367 final var response = captor.getValue();
368 assertEquals(201, response.getStatus());
369 assertEquals(URI.create("http://localhost:8181/rests/data/example-jukebox:jukebox/playlist=name%20of%20band"),
370 response.getLocation());
374 public void testDeleteData() {
375 doNothing().when(readWrite).delete(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
376 doReturn(immediateTrueFluentFuture())
377 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
378 final var captor = ArgumentCaptor.forClass(Response.class);
379 doReturn(true).when(asyncResponse).resume(captor.capture());
380 dataService.deleteData("example-jukebox:jukebox", asyncResponse);
382 assertEquals(204, captor.getValue().getStatus());
386 public void testDeleteDataNotExisting() {
387 doReturn(immediateFalseFluentFuture())
388 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
389 final var captor = ArgumentCaptor.forClass(RestconfDocumentedException.class);
390 doReturn(true).when(asyncResponse).resume(captor.capture());
391 dataService.deleteData("example-jukebox:jukebox", asyncResponse);
393 final var errors = captor.getValue().getErrors();
394 assertEquals(1, errors.size());
395 final var error = errors.get(0);
396 assertEquals(ErrorType.PROTOCOL, error.getErrorType());
397 assertEquals(ErrorTag.DATA_MISSING, error.getErrorTag());
401 * Test of deleting data on mount point.
404 public void testDeleteDataMountPoint() {
405 doNothing().when(readWrite).delete(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
406 doReturn(immediateTrueFluentFuture())
407 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
408 final var captor = ArgumentCaptor.forClass(Response.class);
409 doReturn(true).when(asyncResponse).resume(captor.capture());
410 dataService.deleteData("example-jukebox:jukebox/yang-ext:mount/example-jukebox:jukebox", asyncResponse);
412 assertEquals(204, captor.getValue().getStatus());
416 public void testPatchData() {
417 final var patch = new PatchContext("test patch id", List.of(
418 new PatchEntity("create data", Operation.Create, JUKEBOX_IID, EMPTY_JUKEBOX),
419 new PatchEntity("replace data", Operation.Replace, JUKEBOX_IID, EMPTY_JUKEBOX),
420 new PatchEntity("delete data", Operation.Delete, GAP_IID)));
422 doNothing().when(readWrite).delete(LogicalDatastoreType.CONFIGURATION, GAP_IID);
423 doReturn(immediateFalseFluentFuture())
424 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
425 doReturn(immediateTrueFluentFuture())
426 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, GAP_IID);
427 doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
428 dataService.yangPatchData(JUKEBOX_SCHEMA, patch, null, asyncResponse);
429 final var response = responseCaptor.getValue();
430 assertEquals(200, response.getStatus());
431 final var status = assertInstanceOf(PatchStatusContext.class, response.getEntity());
433 assertTrue(status.ok());
434 assertEquals(3, status.editCollection().size());
435 assertEquals("replace data", status.editCollection().get(1).getEditId());
439 public void testPatchDataMountPoint() throws Exception {
440 final var patch = new PatchContext("test patch id", List.of(
441 new PatchEntity("create data", Operation.Create, JUKEBOX_IID, EMPTY_JUKEBOX),
442 new PatchEntity("replace data", Operation.Replace, JUKEBOX_IID, EMPTY_JUKEBOX),
443 new PatchEntity("delete data", Operation.Delete, GAP_IID)));
445 doNothing().when(readWrite).delete(LogicalDatastoreType.CONFIGURATION, GAP_IID);
446 doReturn(immediateFalseFluentFuture())
447 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
448 doReturn(immediateTrueFluentFuture()).when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, GAP_IID);
450 doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
451 dataService.yangPatchData(JUKEBOX_SCHEMA, patch, mountPoint, asyncResponse);
452 final var response = responseCaptor.getValue();
453 assertEquals(200, response.getStatus());
454 final var status = assertInstanceOf(PatchStatusContext.class, response.getEntity());
456 assertTrue(status.ok());
457 assertEquals(3, status.editCollection().size());
458 assertNull(status.globalErrors());
462 public void testPatchDataDeleteNotExist() {
463 final var patch = new PatchContext("test patch id", List.of(
464 new PatchEntity("create data", Operation.Create, JUKEBOX_IID, EMPTY_JUKEBOX),
465 new PatchEntity("remove data", Operation.Remove, GAP_IID),
466 new PatchEntity("delete data", Operation.Delete, GAP_IID)));
468 doNothing().when(readWrite).delete(LogicalDatastoreType.CONFIGURATION, GAP_IID);
469 doReturn(immediateFalseFluentFuture())
470 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
471 doReturn(immediateFalseFluentFuture())
472 .when(readWrite).exists(LogicalDatastoreType.CONFIGURATION, GAP_IID);
473 doReturn(true).when(readWrite).cancel();
475 doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
476 dataService.yangPatchData(JUKEBOX_SCHEMA, patch, null, asyncResponse);
477 final var response = responseCaptor.getValue();
478 assertEquals(200, response.getStatus());
479 final var status = assertInstanceOf(PatchStatusContext.class, response.getEntity());
481 assertFalse(status.ok());
482 assertEquals(3, status.editCollection().size());
483 assertTrue(status.editCollection().get(0).isOk());
484 assertTrue(status.editCollection().get(1).isOk());
485 assertFalse(status.editCollection().get(2).isOk());
486 assertFalse(status.editCollection().get(2).getEditErrors().isEmpty());
487 final String errorMessage = status.editCollection().get(2).getEditErrors().get(0).getErrorMessage();
488 assertEquals("Data does not exist", errorMessage);