diff --git a/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java b/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java index 4ae928af59..1a73341ea5 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java +++ b/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java @@ -47,6 +47,7 @@ import io.cryostat.recordings.RecordingMetadataManager.Metadata; import io.cryostat.recordings.RecordingOptionsBuilderFactory; import io.cryostat.recordings.RecordingTargetHelper; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; @@ -151,10 +152,11 @@ public void handleAuthenticated(RoutingContext ctx) throws Exception { recordingOptionsBuilderFactory .create(connection.getService()) .name(recordingName); - boolean restart = false; - if (attrs.contains("restart")) { - restart = Boolean.parseBoolean(attrs.get("restart")); - } + + String replace = attrs.get("replace"); + ReplacementPolicy replacementPolicy = + ReplacementPolicy.fromString(replace); + if (attrs.contains("duration")) { builder = builder.duration( @@ -199,7 +201,7 @@ public void handleAuthenticated(RoutingContext ctx) throws Exception { eventSpecifier); IRecordingDescriptor descriptor = recordingTargetHelper.startRecording( - restart, + replacementPolicy, connectionDescriptor, builder.build(), template.getLeft(), diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/graph/StartRecordingOnTargetMutator.java b/src/main/java/io/cryostat/net/web/http/api/v2/graph/StartRecordingOnTargetMutator.java index 1b6364c3fb..6127d7f82a 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/graph/StartRecordingOnTargetMutator.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/graph/StartRecordingOnTargetMutator.java @@ -42,6 +42,7 @@ import io.cryostat.recordings.RecordingMetadataManager.Metadata; import io.cryostat.recordings.RecordingOptionsBuilderFactory; import io.cryostat.recordings.RecordingTargetHelper; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -110,13 +111,15 @@ public HyperlinkedSerializableRecordingDescriptor getAuthenticated( return targetConnectionManager.executeConnectedTask( cd, conn -> { - boolean restart = false; + ReplacementPolicy replace = ReplacementPolicy.NEVER; RecordingOptionsBuilder builder = recordingOptionsBuilderFactory .create(conn.getService()) .name((String) settings.get("name")); - if (settings.containsKey("restart")) { - restart = Boolean.TRUE.equals(settings.get("restart")); + + if (settings.containsKey("replace")) { + String replaceValue = (String) settings.get("replace"); + replace = ReplacementPolicy.fromString(replaceValue); } if (settings.containsKey("duration")) { builder = @@ -157,7 +160,7 @@ public HyperlinkedSerializableRecordingDescriptor getAuthenticated( } IRecordingDescriptor desc = recordingTargetHelper.startRecording( - restart, + replace, cd, builder.build(), (String) settings.get("template"), diff --git a/src/main/java/io/cryostat/recordings/RecordingTargetHelper.java b/src/main/java/io/cryostat/recordings/RecordingTargetHelper.java index 7831bfcbbd..e0e9e7bdbe 100644 --- a/src/main/java/io/cryostat/recordings/RecordingTargetHelper.java +++ b/src/main/java/io/cryostat/recordings/RecordingTargetHelper.java @@ -114,8 +114,25 @@ public List getRecordings(ConnectionDescriptor connectionD connection -> connection.getService().getAvailableRecordings()); } + public enum ReplacementPolicy { + ALWAYS, + STOPPED, + NEVER; + + public static ReplacementPolicy fromString(String value) { + if (value == null) { + return NEVER; + } + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return NEVER; + } + } + } + public IRecordingDescriptor startRecording( - boolean restart, + ReplacementPolicy replace, ConnectionDescriptor connectionDescriptor, IConstrainedMap recordingOptions, String templateName, @@ -124,6 +141,7 @@ public IRecordingDescriptor startRecording( boolean archiveOnStop) throws Exception { String recordingName = (String) recordingOptions.get(RecordingOptionsBuilder.KEY_NAME); + return targetConnectionManager.executeConnectedTask( connectionDescriptor, connection -> { @@ -132,13 +150,18 @@ public IRecordingDescriptor startRecording( Optional previous = getDescriptorByName(connection, recordingName); if (previous.isPresent()) { - if (!restart) { + if (shouldRestartRecording(replace, previous.get())) { + if (isRecordingStopped(previous.get())) { + connection.getService().close(previous.get()); + } else { + // If recording exists & running, close it before starting new one + connection.getService().close(previous.get()); + } + } else { throw new IllegalArgumentException( String.format( "Recording with name \"%s\" already exists", recordingName)); - } else { - connection.getService().close(previous.get()); } } IRecordingDescriptor desc = @@ -175,15 +198,33 @@ public IRecordingDescriptor startRecording( if (fixedDuration != null) { Long delay = Long.valueOf(fixedDuration.toString().replaceAll("[^0-9]", "")); - scheduleRecordingTasks( recordingName, delay, connectionDescriptor, archiveOnStop); } - return desc; }); } + private boolean shouldRestartRecording( + ReplacementPolicy replace, IRecordingDescriptor descriptor) { + if (replace != null) { + switch (replace) { + case ALWAYS: + return true; + case STOPPED: + return descriptor.getState() == IRecordingDescriptor.RecordingState.STOPPED; + case NEVER: + return false; + } + } + // If neither restart nor replace is specified, default to never + return false; + } + + private boolean isRecordingStopped(IRecordingDescriptor recording) { + return recording.getState() == IRecordingDescriptor.RecordingState.STOPPED; + } + /** * The returned {@link InputStream}, if any, is only readable while the remote connection * remains open. And so, {@link diff --git a/src/main/java/io/cryostat/rules/RuleProcessor.java b/src/main/java/io/cryostat/rules/RuleProcessor.java index 2a9d36db81..d38fb4cc8e 100644 --- a/src/main/java/io/cryostat/rules/RuleProcessor.java +++ b/src/main/java/io/cryostat/rules/RuleProcessor.java @@ -48,6 +48,7 @@ import io.cryostat.recordings.RecordingMetadataManager.Metadata; import io.cryostat.recordings.RecordingOptionsBuilderFactory; import io.cryostat.recordings.RecordingTargetHelper; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import io.cryostat.rules.RuleRegistry.RuleEvent; import io.cryostat.util.events.Event; import io.cryostat.util.events.EventListener; @@ -383,7 +384,7 @@ private void startRuleRecording(ConnectionDescriptor connectionDescriptor, Rule RecordingTargetHelper.parseEventSpecifierToTemplate( rule.getEventSpecifier()); return recordingTargetHelper.startRecording( - true, + ReplacementPolicy.ALWAYS, connectionDescriptor, builder.build(), template.getLeft(), diff --git a/src/main/resources/types.graphqls b/src/main/resources/types.graphqls index de8677eabf..d4a29180c6 100644 --- a/src/main/resources/types.graphqls +++ b/src/main/resources/types.graphqls @@ -7,6 +7,12 @@ scalar Long scalar Int scalar Float +enum ReplacementPolicy { + ALWAYS + STOPPED + NEVER +} + input EnvironmentNodeFilterInput { id: Int name: String @@ -166,7 +172,7 @@ type RecordingMetadata { } input RecordingSettings { - restart: Boolean + replace: ReplacementPolicy name: String! template: String! templateType: String! diff --git a/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java index e2cbd15839..3b7fb2fd79 100644 --- a/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java @@ -43,6 +43,7 @@ import io.cryostat.recordings.RecordingMetadataManager.Metadata; import io.cryostat.recordings.RecordingOptionsBuilderFactory; import io.cryostat.recordings.RecordingTargetHelper; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import com.google.gson.Gson; import io.vertx.core.MultiMap; @@ -172,6 +173,7 @@ void shouldStartRecording() throws Exception { attrs.add("maxAge", "50"); attrs.add("maxSize", "64"); attrs.add("archiveOnStop", "false"); + Mockito.when(ctx.response()).thenReturn(resp); Mockito.when( resp.putHeader( @@ -181,7 +183,7 @@ void shouldStartRecording() throws Exception { IRecordingDescriptor descriptor = createDescriptor("someRecording"); Mockito.when( recordingTargetHelper.startRecording( - Mockito.anyBoolean(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), @@ -201,7 +203,8 @@ void shouldStartRecording() throws Exception { Mockito.verify(recordingOptionsBuilder).maxAge(50L); Mockito.verify(recordingOptionsBuilder).maxSize(64L); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -220,7 +223,7 @@ void shouldStartRecording() throws Exception { Mockito.verify(recordingTargetHelper) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), @@ -228,7 +231,8 @@ void shouldStartRecording() throws Exception { metadataCaptor.capture(), archiveOnStopCaptor.capture()); - MatcherAssert.assertThat(restartCaptor.getValue(), Matchers.equalTo(false)); + MatcherAssert.assertThat( + replaceCaptor.getValue(), Matchers.equalTo(ReplacementPolicy.NEVER)); ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); MatcherAssert.assertThat( @@ -285,7 +289,7 @@ void shouldRestartRecording() throws Exception { IRecordingDescriptor descriptor = createDescriptor("someRecording"); Mockito.when( recordingTargetHelper.startRecording( - Mockito.anyBoolean(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), @@ -307,7 +311,7 @@ void shouldRestartRecording() throws Exception { resp.putHeader( Mockito.any(CharSequence.class), Mockito.any(CharSequence.class))) .thenReturn(resp); - attrs.add("restart", "true"); + attrs.add("replace", "always"); attrs.add("recordingName", "someRecording"); attrs.add("events", "template=Foo"); @@ -315,7 +319,8 @@ void shouldRestartRecording() throws Exception { Mockito.verify(recordingOptionsBuilder).name("someRecording"); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -334,7 +339,7 @@ void shouldRestartRecording() throws Exception { Mockito.verify(recordingTargetHelper) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), @@ -342,7 +347,8 @@ void shouldRestartRecording() throws Exception { metadataCaptor.capture(), archiveOnStopCaptor.capture()); - MatcherAssert.assertThat(restartCaptor.getValue(), Matchers.equalTo(true)); + MatcherAssert.assertThat( + replaceCaptor.getValue(), Matchers.equalTo(ReplacementPolicy.ALWAYS)); ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); MatcherAssert.assertThat( @@ -389,7 +395,7 @@ void shouldHandleNameCollision() throws Exception { Mockito.when(recordingOptionsBuilder.build()).thenReturn(recordingOptions); Mockito.when( recordingTargetHelper.startRecording( - Mockito.anyBoolean(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), @@ -552,7 +558,7 @@ void shouldStartRecordingAndArchiveOnStop() throws Exception { IRecordingDescriptor descriptor = createDescriptor("someRecording"); Mockito.when( recordingTargetHelper.startRecording( - Mockito.anyBoolean(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), @@ -572,7 +578,8 @@ void shouldStartRecordingAndArchiveOnStop() throws Exception { Mockito.verify(recordingOptionsBuilder).maxAge(50L); Mockito.verify(recordingOptionsBuilder).maxSize(64L); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -591,7 +598,7 @@ void shouldStartRecordingAndArchiveOnStop() throws Exception { Mockito.verify(recordingTargetHelper) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), @@ -599,7 +606,8 @@ void shouldStartRecordingAndArchiveOnStop() throws Exception { metadataCaptor.capture(), archiveOnStopCaptor.capture()); - MatcherAssert.assertThat(restartCaptor.getValue(), Matchers.equalTo(false)); + MatcherAssert.assertThat( + replaceCaptor.getValue(), Matchers.equalTo(ReplacementPolicy.NEVER)); ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); MatcherAssert.assertThat( diff --git a/src/test/java/io/cryostat/recordings/RecordingTargetHelperTest.java b/src/test/java/io/cryostat/recordings/RecordingTargetHelperTest.java index 29f0bf0803..1b330dece9 100644 --- a/src/test/java/io/cryostat/recordings/RecordingTargetHelperTest.java +++ b/src/test/java/io/cryostat/recordings/RecordingTargetHelperTest.java @@ -15,7 +15,7 @@ */ package io.cryostat.recordings; -import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.*; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -53,6 +53,7 @@ import io.cryostat.net.web.WebServer; import io.cryostat.net.web.http.HttpMimeType; import io.cryostat.recordings.RecordingMetadataManager.Metadata; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import io.cryostat.recordings.RecordingTargetHelper.SnapshotCreationException; import io.vertx.core.Vertx; @@ -730,7 +731,7 @@ public Future answer(InvocationOnMock invocation) }); recordingTargetHelper.startRecording( - false, + ReplacementPolicy.NEVER, connectionDescriptor, recordingOptions, templateName, @@ -784,6 +785,187 @@ public Future answer(InvocationOnMock invocation) Map.of("template.name", "Profiling", "template.type", "TARGET")))); } + void shouldReplaceExistingRecording() throws Exception { + String recordingName = "existingRecording"; + String targetId = "fooTarget"; + String duration = "5000ms"; + String templateName = "Profiling"; + TemplateType templateType = TemplateType.TARGET; + ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(targetId); + IRecordingDescriptor existingRecording = createDescriptor(recordingName); + IConstrainedMap recordingOptions = Mockito.mock(IConstrainedMap.class); + Metadata metadata = + new Metadata(Map.of("template.name", "Profiling", "template.type", "TARGET")); + + Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + invocation -> { + TargetConnectionManager.ConnectedTask task = invocation.getArgument(1); + return task.execute(connection); + }); + + Mockito.when(recordingOptions.get(Mockito.any())).thenReturn(recordingName, duration); + + Mockito.when(connection.getService()).thenReturn(service); + List existingRecordings = List.of(existingRecording); + Mockito.when(service.getAvailableRecordings()).thenReturn(existingRecordings); + + Mockito.when(service.start(Mockito.any(), Mockito.any())).thenReturn(existingRecording); + + Mockito.when(existingRecording.getState()).thenReturn(RecordingState.STOPPED); + + TemplateService templateService = Mockito.mock(TemplateService.class); + IConstrainedMap events = Mockito.mock(IConstrainedMap.class); + Mockito.when(connection.getTemplateService()).thenReturn(templateService); + Mockito.when(templateService.getEvents(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(events)); + + Mockito.when( + recordingMetadataManager.setRecordingMetadata( + Mockito.any(), Mockito.anyString(), Mockito.any(Metadata.class))) + .thenAnswer( + invocation -> { + return CompletableFuture.completedFuture(invocation.getArgument(2)); + }); + + Mockito.doNothing().when(notification).send(); + + recordingTargetHelper.startRecording( + ReplacementPolicy.ALWAYS, + connectionDescriptor, + recordingOptions, + templateName, + templateType, + metadata, + false); + + Mockito.verify(service).close(existingRecording); + Mockito.verify(service).start(Mockito.any(), Mockito.any()); + + // Verify notification not sent because recording exists and no new recording is created + Mockito.verify(notification, Mockito.times(0)).send(); + } + + @Test + void shouldCloseAndRecreateIfRecordingExistsAndIsRunning() throws Exception { + String recordingName = "existingRecording"; + String targetId = "fooTarget"; + String duration = "5000ms"; + String templateName = "Profiling"; + TemplateType templateType = TemplateType.TARGET; + ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(targetId); + IRecordingDescriptor existingRecording = createDescriptor(recordingName); + IConstrainedMap recordingOptions = Mockito.mock(IConstrainedMap.class); + Metadata metadata = + new Metadata(Map.of("template.name", "Profiling", "template.type", "TARGET")); + + Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + invocation -> { + TargetConnectionManager.ConnectedTask task = invocation.getArgument(1); + return task.execute(connection); + }); + + Mockito.when(recordingOptions.get(Mockito.any())).thenReturn(recordingName, duration); + + Mockito.when(connection.getService()).thenReturn(service); + List existingRecordings = List.of(existingRecording); + Mockito.when(service.getAvailableRecordings()).thenReturn(existingRecordings); + + Mockito.when(service.start(Mockito.any(), Mockito.any())).thenReturn(existingRecording); + + Mockito.when(existingRecording.getState()).thenReturn(RecordingState.RUNNING); + + TemplateService templateService = Mockito.mock(TemplateService.class); + IConstrainedMap events = Mockito.mock(IConstrainedMap.class); + Mockito.when(connection.getTemplateService()).thenReturn(templateService); + Mockito.when(templateService.getEvents(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(events)); + + Mockito.when( + recordingMetadataManager.setRecordingMetadata( + Mockito.any(), Mockito.anyString(), Mockito.any(Metadata.class))) + .thenAnswer( + invocation -> { + return CompletableFuture.completedFuture(invocation.getArgument(2)); + }); + + Mockito.doNothing().when(notification).send(); + + recordingTargetHelper.startRecording( + ReplacementPolicy.ALWAYS, + connectionDescriptor, + recordingOptions, + templateName, + templateType, + metadata, + false); + + Mockito.verify(service).close(existingRecording); + Mockito.verify(service).start(Mockito.any(), Mockito.any()); + } + + @Test + void shouldRestartRecordingWhenRecordingExistsAndIsStopped() throws Exception { + String recordingName = "existingRecording"; + String targetId = "fooTarget"; + String duration = "5000ms"; + String templateName = "Profiling"; + TemplateType templateType = TemplateType.TARGET; + ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(targetId); + IRecordingDescriptor existingRecording = createDescriptor(recordingName); + IConstrainedMap recordingOptions = Mockito.mock(IConstrainedMap.class); + Metadata metadata = + new Metadata(Map.of("template.name", "Profiling", "template.type", "TARGET")); + + Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + invocation -> { + TargetConnectionManager.ConnectedTask task = invocation.getArgument(1); + return task.execute(connection); + }); + + Mockito.when(recordingOptions.get(Mockito.any())).thenReturn(recordingName, duration); + + Mockito.when(connection.getService()).thenReturn(service); + List existingRecordings = List.of(existingRecording); + Mockito.when(service.getAvailableRecordings()).thenReturn(existingRecordings); + + Mockito.when(service.start(Mockito.any(), Mockito.any())).thenReturn(existingRecording); + + Mockito.when(existingRecording.getState()).thenReturn(RecordingState.STOPPED); + + TemplateService templateService = Mockito.mock(TemplateService.class); + IConstrainedMap events = Mockito.mock(IConstrainedMap.class); + Mockito.when(connection.getTemplateService()).thenReturn(templateService); + Mockito.when(templateService.getEvents(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(events)); + + Mockito.when( + recordingMetadataManager.setRecordingMetadata( + Mockito.any(), Mockito.anyString(), Mockito.any(Metadata.class))) + .thenAnswer( + invocation -> { + return CompletableFuture.completedFuture(invocation.getArgument(2)); + }); + + Mockito.doNothing().when(notification).send(); + + recordingTargetHelper.startRecording( + ReplacementPolicy.STOPPED, + connectionDescriptor, + recordingOptions, + templateName, + templateType, + metadata, + false); + + Mockito.verify(service).close(existingRecording); + Mockito.verify(service).start(Mockito.any(), Mockito.any()); + Mockito.verify(notification).send(); + Mockito.verifyNoMoreInteractions(recordingOptions); + } + @Test void shouldStopRecording() throws Exception { Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) diff --git a/src/test/java/io/cryostat/rules/RuleProcessorTest.java b/src/test/java/io/cryostat/rules/RuleProcessorTest.java index d42afd203b..bb47851bac 100644 --- a/src/test/java/io/cryostat/rules/RuleProcessorTest.java +++ b/src/test/java/io/cryostat/rules/RuleProcessorTest.java @@ -15,7 +15,8 @@ */ package io.cryostat.rules; -import static org.mockito.Mockito.never; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; import java.net.URI; import java.util.Set; @@ -45,6 +46,7 @@ import io.cryostat.recordings.RecordingMetadataManager.Metadata; import io.cryostat.recordings.RecordingOptionsBuilderFactory; import io.cryostat.recordings.RecordingTargetHelper; +import io.cryostat.recordings.RecordingTargetHelper.ReplacementPolicy; import io.cryostat.util.events.Event; import io.cryostat.util.events.EventListener; @@ -53,7 +55,6 @@ import org.apache.commons.lang3.tuple.Pair; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -187,7 +188,8 @@ void testSuccessfulRuleActivationWithCredentials() throws Exception { Mockito.verify(recordingOptionsBuilder).maxAge(30); Mockito.verify(recordingOptionsBuilder).maxSize(1234); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -206,7 +208,7 @@ void testSuccessfulRuleActivationWithCredentials() throws Exception { Mockito.verify(recordingTargetHelper) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), @@ -214,7 +216,8 @@ void testSuccessfulRuleActivationWithCredentials() throws Exception { metadataCaptor.capture(), archiveOnStopCaptor.capture()); - Assertions.assertTrue(restartCaptor.getValue()); + MatcherAssert.assertThat( + replaceCaptor.getValue(), Matchers.equalTo(ReplacementPolicy.ALWAYS)); ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); MatcherAssert.assertThat( @@ -440,7 +443,8 @@ public Void answer(InvocationOnMock invocation) throws Throwable { Mockito.verify(recordingOptionsBuilder).maxAge(30); Mockito.verify(recordingOptionsBuilder).maxSize(1234); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -459,7 +463,7 @@ public Void answer(InvocationOnMock invocation) throws Throwable { Mockito.verify(recordingTargetHelper) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), @@ -467,7 +471,8 @@ public Void answer(InvocationOnMock invocation) throws Throwable { metadataCaptor.capture(), archiveOnStopCaptor.capture()); - Assertions.assertTrue(restartCaptor.getValue()); + MatcherAssert.assertThat( + replaceCaptor.getValue(), Matchers.equalTo(ReplacementPolicy.ALWAYS)); IConstrainedMap actualRecordingOptions = recordingOptionsCaptor.getValue(); MatcherAssert.assertThat(actualRecordingOptions, Matchers.sameInstance(recordingOptions)); @@ -508,7 +513,8 @@ void testSuccessfulRuleNonActivationWithCredentials() throws Exception { Mockito.verify(recordingOptionsBuilder, never()).name("auto_Test_Rule"); - ArgumentCaptor restartCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor replaceCaptor = + ArgumentCaptor.forClass(ReplacementPolicy.class); ArgumentCaptor connectionDescriptorCaptor = ArgumentCaptor.forClass(ConnectionDescriptor.class); @@ -527,7 +533,7 @@ void testSuccessfulRuleNonActivationWithCredentials() throws Exception { Mockito.verify(recordingTargetHelper, never()) .startRecording( - restartCaptor.capture(), + replaceCaptor.capture(), connectionDescriptorCaptor.capture(), recordingOptionsCaptor.capture(), templateNameCaptor.capture(), diff --git a/src/test/java/itest/GraphQLIT.java b/src/test/java/itest/GraphQLIT.java index 7b90f09b57..8cfe0e2b77 100644 --- a/src/test/java/itest/GraphQLIT.java +++ b/src/test/java/itest/GraphQLIT.java @@ -18,6 +18,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.*; import java.util.ArrayList; import java.util.Arrays; @@ -565,7 +566,8 @@ void testNodesHaveIds() throws Exception { EnvironmentNodesResponse.class)); } }); - // if any of the nodes in the query did not have an ID property then the request would fail + // if any of the nodes in the query did not have an ID property then the request + // would fail EnvironmentNodesResponse actual = resp.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); Set observedIds = new HashSet<>(); for (var env : actual.data.environmentNodes) { @@ -882,7 +884,8 @@ public void shouldReturnArchivedRecordingsFilteredByNames() throws Exception { deleteFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); } - // Retrieve the list of updated archived recordings to verify that the targeted recordings + // Retrieve the list of updated archived recordings to verify that the targeted + // recordings // have been deleted CompletableFuture updatedArchivedRecordingsFuture = new CompletableFuture<>(); webClient @@ -996,6 +999,327 @@ public void testQueryforFilteredEnvironmentNodesByNames() throws Exception { Assertions.assertTrue(nameExists, "Name not found"); } + @Test + @Order(13) + void testReplaceAlwaysOnStoppedRecording() throws Exception { + JsonObject deletedObj = new JsonObject(); + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Stop the Recording + stopRecordingProcess(notificationRecordingHolder); + JsonObject notificationStopRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationStopRecording.getString("name")); + Assertions.assertEquals("STOPPED", notificationStopRecording.getString("state")); + + // Restart the recording with replace:ALWAYS + restartRecordingWithReplaceAlways(notificationRecordingHolder); + JsonObject notificationRestartRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRestartRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRestartRecording.getString("state")); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(14) + void testReplaceNeverOnStoppedRecording() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Stop the Recording + stopRecordingProcess(notificationRecordingHolder); + JsonObject notificationStopRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationStopRecording.getString("name")); + Assertions.assertEquals("STOPPED", notificationStopRecording.getString("state")); + + // Restart the recording with replace:NEVER + restartRecordingWithReplaceNever(); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(15) + void testReplaceStoppedOnStoppedRecording() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Stop the Recording + stopRecordingProcess(notificationRecordingHolder); + JsonObject notificationStopRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationStopRecording.getString("name")); + Assertions.assertEquals("STOPPED", notificationStopRecording.getString("state")); + + // Restart the recording with replace:STOPPED + restartRecordingWithReplaceStopped(notificationRecordingHolder); + JsonObject notificationRecreateRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecreateRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecreateRecording.getString("state")); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(16) + void testReplaceStoppedOnRunningRecording() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Restart the recording with replace:STOPPED + CompletableFuture resp = new CompletableFuture<>(); + CountDownLatch latch = new CountDownLatch(1); + JsonObject query = new JsonObject(); + + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " doStartRecording(recording: { name: \"test\", template:\"Profiling\"," + + " templateType: \"TARGET\", replace:STOPPED }) { name state}} }"); + + Thread.sleep(5000); + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete(ar.result().bodyAsJsonObject()); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + JsonObject response = resp.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonArray errors = response.getJsonArray("errors"); + JsonObject error = errors.getJsonObject(0); + + Assertions.assertTrue( + error.getString("message") + .contains("Recording with name \"test\" already exists"), + "Expected error message to contain 'Recording with name \"test\" already" + + " exists'"); + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(17) + void testReplaceNeverOnRunningRecording() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Restart the recording with replace:NEVER + restartRecordingWithReplaceNever(); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(18) + void testReplaceAlwaysOnRunningRecording() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start a Recording + startRecordingProcess(notificationRecordingHolder); + JsonObject notificationRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecording.getString("state")); + + // Restart the recording with replace:ALWAYS + restartRecordingWithReplaceAlways(notificationRecordingHolder); + JsonObject notificationRestartRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRestartRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRestartRecording.getString("state")); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(19) + void testStartRecordingwithReplaceNever() throws Exception { + CountDownLatch latch = new CountDownLatch(2); + JsonObject deletedObj = new JsonObject(); + JsonObject query = new JsonObject(); + + try { + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: { annotations: \"PORT == 9093\" }) {" + + " doStartRecording(recording: { name: \"test\",template:" + + " \"Profiling\", templateType: \"TARGET\", replace:NEVER }) { name" + + " state}} }"); + Future f = + worker.submit( + () -> { + try { + return expectNotification( + "ActiveRecordingCreated", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); + + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + StartRecordingMutationResponse.class)); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + // Ensure Active Recording is Created + JsonObject notification = f.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonObject notificationCreateRecording = + notification.getJsonObject("message").getJsonObject("recording"); + + Assertions.assertEquals("test", notificationCreateRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationCreateRecording.getString("state")); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(20) + void testStartRecordingwithReplaceAlways() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Start the recording with replace:ALWAYS + restartRecordingWithReplaceAlways(notificationRecordingHolder); + JsonObject notificationRestartRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRestartRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRestartRecording.getString("state")); + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + + @Test + @Order(21) + void testStartRecordingwithReplaceStopped() throws Exception { + JsonObject[] notificationRecordingHolder = new JsonObject[1]; + JsonObject deletedObj = new JsonObject(); + + try { + // Restart the recording with replace:STOPPED + restartRecordingWithReplaceStopped(notificationRecordingHolder); + JsonObject notificationRecreateRecording = notificationRecordingHolder[0]; + + Assertions.assertEquals("test", notificationRecreateRecording.getString("name")); + Assertions.assertEquals("RUNNING", notificationRecreateRecording.getString("state")); + + } finally { + // Delete the Recording + deleteRecordingProcess(deletedObj); + Assertions.assertNull(deletedObj.getString("name")); + Assertions.assertNull(deletedObj.getString("state")); + } + } + static class Target { String alias; String serviceUri; @@ -1732,4 +2056,279 @@ public boolean equals(Object obj) { return Objects.equals(data, other.data); } } + + // start recording + private void startRecordingProcess(JsonObject[] notificationRecordingHolder) throws Exception { + JsonObject query = new JsonObject(); + CountDownLatch latch = new CountDownLatch(2); + + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: {" + + " name:\"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " doStartRecording(recording: { name: \"test\", template:\"Profiling\"," + + " templateType: \"TARGET\"}) { name state}} }"); + Future f = + worker.submit( + () -> { + try { + return expectNotification( + "ActiveRecordingCreated", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); + + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + StartRecordingMutationResponse.class)); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + JsonObject notification = f.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonObject notificationRecording = + notification.getJsonObject("message").getJsonObject("recording"); + + notificationRecordingHolder[0] = notificationRecording; + } + + // Stop the Recording + private void stopRecordingProcess(JsonObject[] notificationRecordingHolder) throws Exception { + JsonObject query = new JsonObject(); + CountDownLatch latch = new CountDownLatch(2); + + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " recordings { active { data { doStop { name state } } } } } }"); + + Future f2 = + worker.submit( + () -> { + try { + return expectNotification( + "ActiveRecordingStopped", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); + + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + StartRecordingMutationResponse.class)); + latch.countDown(); + } + }); + + latch.await(30, TimeUnit.SECONDS); + + JsonObject notification = f2.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonObject notificationRecording = + notification.getJsonObject("message").getJsonObject("recording"); + + notificationRecordingHolder[0] = notificationRecording; + } + + // Delete the Recording + private void deleteRecordingProcess(JsonObject deletedObj) throws Exception { + JsonObject query = new JsonObject(); + CountDownLatch latch = new CountDownLatch(1); + + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " recordings { active { data { doDelete { name state } } } } } }"); + + Thread.sleep(5000); + + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), JsonObject.class)); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + deletedObj = resp.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + // Restart the recording with replace:ALWAYS + private void restartRecordingWithReplaceAlways(JsonObject[] notificationRecordingHolder) + throws Exception { + JsonObject query = new JsonObject(); + CountDownLatch latch = new CountDownLatch(2); + + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " doStartRecording(recording: { name: \"test\", template:\"Profiling\"," + + " templateType: \"TARGET\", replace:ALWAYS}) { name state}} }"); + Future f = + worker.submit( + () -> { + try { + return expectNotification( + "ActiveRecordingCreated", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); + + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + StartRecordingMutationResponse.class)); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + JsonObject notification = f.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonObject notificationRecording = + notification.getJsonObject("message").getJsonObject("recording"); + + notificationRecordingHolder[0] = notificationRecording; + } + + // Restart the recording with replace:STOPPED (utilise this helper ONLY when Recording is + // STOPPED) + private void restartRecordingWithReplaceStopped(JsonObject[] notificationRecordingHolder) + throws Exception { + CountDownLatch latch = new CountDownLatch(2); + JsonObject query = new JsonObject(); + + CompletableFuture resp = new CompletableFuture<>(); + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " doStartRecording(recording: { name: \"test\", template:\"Profiling\"," + + " templateType: \"TARGET\", replace:STOPPED}) { name state}} }"); + + Future f = + worker.submit( + () -> { + try { + return expectNotification( + "ActiveRecordingCreated", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + StartRecordingMutationResponse.class)); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + JsonObject notification = f.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonObject notificationRecording = + notification.getJsonObject("message").getJsonObject("recording"); + + notificationRecordingHolder[0] = notificationRecording; + } + + // Restart a Recording with replace:NEVER (could be used with Replace:STOPPED on a running + // recording) + private void restartRecordingWithReplaceNever() throws Exception { + CompletableFuture resp = new CompletableFuture<>(); + CountDownLatch latch = new CountDownLatch(1); + JsonObject query = new JsonObject(); + + query.put( + "query", + "query { targetNodes(filter: { name:" + + " \"service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi\" }) {" + + " doStartRecording(recording: { name: \"test\", template:\"Profiling\"," + + " templateType: \"TARGET\", replace:NEVER }) { name state}} }"); + + Thread.sleep(5000); + webClient + .post("/api/v2.2/graphql") + .sendJson( + query, + ar -> { + if (assertRequestStatus(ar, resp)) { + resp.complete(ar.result().bodyAsJsonObject()); + latch.countDown(); + } + }); + + latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + JsonObject response = resp.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonArray errors = response.getJsonArray("errors"); + JsonObject error = errors.getJsonObject(0); + + Assertions.assertTrue( + error.getString("message").contains("Recording with name \"test\" already exists"), + "Expected error message to contain 'Recording with name \"test\" already exists'"); + } }