From 471cc479f9b0b6c5a9cf8c848dc2db379e16d4dc Mon Sep 17 00:00:00 2001 From: Thiago dos Santos Hora Date: Fri, 4 Oct 2024 12:00:14 +0200 Subject: [PATCH] [OPIK-159] Experiment delete endpoint (#336) * [OPIK-159] Experiment delete endpoint * Add Batch delete * Add auth tests for experiments delete * Fix tests --- .../com/comet/opik/api/ExperimentsDelete.java | 17 ++ .../v1/priv/ExperimentsResource.java | 16 ++ .../com/comet/opik/domain/ExperimentDAO.java | 33 +++ .../comet/opik/domain/ExperimentItemDAO.java | 35 +++ .../comet/opik/domain/ExperimentService.java | 7 + .../redis/RedisRateLimitService.java | 14 +- .../v1/priv/ExperimentsResourceTest.java | 247 ++++++++++++++---- 7 files changed, 315 insertions(+), 54 deletions(-) create mode 100644 apps/opik-backend/src/main/java/com/comet/opik/api/ExperimentsDelete.java diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/ExperimentsDelete.java b/apps/opik-backend/src/main/java/com/comet/opik/api/ExperimentsDelete.java new file mode 100644 index 0000000000..6751955c52 --- /dev/null +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/ExperimentsDelete.java @@ -0,0 +1,17 @@ +package com.comet.opik.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.Set; +import java.util.UUID; + +@Builder(toBuilder = true) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record ExperimentsDelete(@NotNull @Size(min = 1, max = 1000) Set ids) { +} diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/ExperimentsResource.java b/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/ExperimentsResource.java index 9ce949c97e..8f24fba3d3 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/ExperimentsResource.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/ExperimentsResource.java @@ -7,6 +7,7 @@ import com.comet.opik.api.ExperimentItemsBatch; import com.comet.opik.api.ExperimentItemsDelete; import com.comet.opik.api.ExperimentSearchCriteria; +import com.comet.opik.api.ExperimentsDelete; import com.comet.opik.domain.ExperimentItemService; import com.comet.opik.domain.ExperimentService; import com.comet.opik.domain.IdGenerator; @@ -132,6 +133,21 @@ public Response create( return Response.created(uri).build(); } + @POST + @Path("/delete") + @Operation(operationId = "deleteExperimentsById", summary = "Delete experiments by id", description = "Delete experiments by id", responses = { + @ApiResponse(responseCode = "204", description = "No content")}) + public Response deleteExperimentsById( + @RequestBody(content = @Content(schema = @Schema(implementation = ExperimentsDelete.class))) @NotNull @Valid ExperimentsDelete request) { + + log.info("Deleting experiments, count '{}'", request.ids()); + experimentService.delete(request.ids()) + .contextWrite(ctx -> setRequestContext(ctx, requestContext)) + .block(); + log.info("Deleted experiments, count '{}'", request.ids()); + return Response.noContent().build(); + } + // Experiment Item Resources @GET diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentDAO.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentDAO.java index c467b0ff87..d31d860f01 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentDAO.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentDAO.java @@ -22,6 +22,7 @@ import org.stringtemplate.v4.ST; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; import java.math.BigDecimal; import java.time.Instant; @@ -362,6 +363,13 @@ AND ilike(name, CONCAT('%', :name, '%')) ; """; + private static final String DELETE_BY_IDS = """ + DELETE FROM experiments + WHERE id IN :ids + AND workspace_id = :workspace_id + ; + """; + private final @NonNull ConnectionFactory connectionFactory; Mono insert(@NonNull Experiment experiment) { @@ -526,4 +534,29 @@ public Flux getExperimentWorkspaces(@NonNull Set e row.get("workspace_id", String.class), row.get("id", UUID.class)))); } + + public Mono delete(Set ids) { + + Preconditions.checkArgument(CollectionUtils.isNotEmpty(ids), "Argument 'ids' must not be empty"); + + log.info("Deleting experiments by ids [{}]", Arrays.toString(ids.toArray())); + + return Mono.from(connectionFactory.create()) + .flatMapMany(connection -> delete(ids, connection)) + .reduce(Long::sum) + .doFinally(signalType -> { + if (signalType == SignalType.ON_COMPLETE) { + log.info("Deleted experiments by ids [{}]", Arrays.toString(ids.toArray())); + } + }); + } + + private Publisher delete(Set ids, Connection connection) { + + var statement = connection.createStatement(DELETE_BY_IDS) + .bind("ids", ids.toArray(UUID[]::new)); + + return makeFluxContextAware(bindWorkspaceIdToFlux(statement)) + .flatMap(Result::getRowsUpdated); + } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentItemDAO.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentItemDAO.java index d437a7d309..78c9ad43ac 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentItemDAO.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentItemDAO.java @@ -16,8 +16,10 @@ import org.stringtemplate.v4.ST; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; import java.time.Instant; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Set; @@ -117,6 +119,13 @@ INSERT INTO experiment_items ( ; """; + private static final String DELETE_BY_EXPERIMENT_ID = """ + DELETE FROM experiment_items + WHERE experiment_id IN :experiment_ids + AND workspace_id = :workspace_id + ; + """; + private final @NonNull ConnectionFactory connectionFactory; public Flux findExperimentSummaryByDatasetIds(Collection datasetIds) { @@ -260,4 +269,30 @@ private Publisher delete(Set ids, Connection connection) return makeFluxContextAware(bindWorkspaceIdToFlux(statement)); } + + public Mono deleteByExperimentIds(Set experimentIds) { + + Preconditions.checkArgument(CollectionUtils.isNotEmpty(experimentIds), + "Argument 'experimentIds' must not be empty"); + + log.info("Deleting experiment items by experiment ids [{}]", Arrays.toString(experimentIds.toArray())); + + return Mono.from(connectionFactory.create()) + .flatMapMany(connection -> deleteByExperimentIds(experimentIds, connection)) + .reduce(0L, Long::sum) + .doFinally(signalType -> { + if (signalType == SignalType.ON_COMPLETE) { + log.info("Deleted experiment items by experiment ids [{}]", + Arrays.toString(experimentIds.toArray())); + } + }); + } + + private Publisher deleteByExperimentIds(Set ids, Connection connection) { + Statement statement = connection.createStatement(DELETE_BY_EXPERIMENT_ID) + .bind("experiment_ids", ids.toArray(UUID[]::new)); + + return makeFluxContextAware(bindWorkspaceIdToFlux(statement)) + .flatMap(Result::getRowsUpdated); + } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentService.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentService.java index 3e06bac95b..43ab84b1a9 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentService.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/ExperimentService.java @@ -32,6 +32,7 @@ public class ExperimentService { private final @NonNull ExperimentDAO experimentDAO; + private final @NonNull ExperimentItemDAO experimentItemDAO; private final @NonNull DatasetService datasetService; private final @NonNull IdGenerator idGenerator; private final @NonNull NameGenerator nameGenerator; @@ -147,4 +148,10 @@ public Mono validateExperimentWorkspace(@NonNull String workspaceId, @N return experimentDAO.getExperimentWorkspaces(experimentIds) .all(experimentWorkspace -> workspaceId.equals(experimentWorkspace.workspaceId())); } + + public Mono delete(@NonNull Set ids) { + return experimentDAO.delete(ids) + .then(Mono.defer(() -> experimentItemDAO.deleteByExperimentIds(ids))) + .then(); + } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisRateLimitService.java b/apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisRateLimitService.java index 83a27fce02..f92a0ca71a 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisRateLimitService.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisRateLimitService.java @@ -21,7 +21,8 @@ public class RedisRateLimitService implements RateLimitService { private final RedissonReactiveClient redisClient; @Override - public Mono isLimitExceeded(@NonNull String apiKey, long events, @NonNull String bucketName, @NonNull LimitConfig limitConfig) { + public Mono isLimitExceeded(@NonNull String apiKey, long events, @NonNull String bucketName, + @NonNull LimitConfig limitConfig) { RRateLimiterReactive rateLimit = redisClient.getRateLimiter(KEY.formatted(bucketName, apiKey)); @@ -32,19 +33,24 @@ public Mono isLimitExceeded(@NonNull String apiKey, long events, @NonNu private Mono setLimitIfNecessary(long limit, long limitDurationInSeconds, RRateLimiterReactive rateLimit) { return rateLimit.isExists() - .flatMap(exists -> Boolean.TRUE.equals(exists) ? Mono.empty() : rateLimit.trySetRate(RateType.OVERALL, limit, limitDurationInSeconds, RateIntervalUnit.SECONDS)) + .flatMap(exists -> Boolean.TRUE.equals(exists) + ? Mono.empty() + : rateLimit.trySetRate(RateType.OVERALL, limit, limitDurationInSeconds, + RateIntervalUnit.SECONDS)) .then(Mono.defer(() -> rateLimit.expireIfNotSet(Duration.ofSeconds(limitDurationInSeconds)))); } @Override - public Mono availableEvents(@NonNull String apiKey, @NonNull String bucketName, @NonNull LimitConfig limitConfig) { + public Mono availableEvents(@NonNull String apiKey, @NonNull String bucketName, + @NonNull LimitConfig limitConfig) { RRateLimiterReactive rateLimit = redisClient.getRateLimiter(KEY.formatted(bucketName, apiKey)); return setLimitIfNecessary(limitConfig.limit(), limitConfig.durationInSeconds(), rateLimit) .then(Mono.defer(rateLimit::availablePermits)); } @Override - public Mono getRemainingTTL(@NonNull String apiKey, @NonNull String bucketName, @NonNull LimitConfig limitConfig) { + public Mono getRemainingTTL(@NonNull String apiKey, @NonNull String bucketName, + @NonNull LimitConfig limitConfig) { RRateLimiterReactive rateLimit = redisClient.getRateLimiter(KEY.formatted(bucketName, apiKey)); return setLimitIfNecessary(limitConfig.limit(), limitConfig.durationInSeconds(), rateLimit) .then(Mono.defer(rateLimit::remainTimeToLive)); diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/ExperimentsResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/ExperimentsResourceTest.java index f2cf8a0fbf..d5e08f5369 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/ExperimentsResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/ExperimentsResourceTest.java @@ -7,6 +7,7 @@ import com.comet.opik.api.ExperimentItemStreamRequest; import com.comet.opik.api.ExperimentItemsBatch; import com.comet.opik.api.ExperimentItemsDelete; +import com.comet.opik.api.ExperimentsDelete; import com.comet.opik.api.FeedbackScore; import com.comet.opik.api.FeedbackScoreAverage; import com.comet.opik.api.FeedbackScoreBatch; @@ -72,7 +73,6 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; -import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -88,6 +88,12 @@ import static com.github.tomakehurst.wiremock.client.WireMock.okJson; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static java.util.stream.Collectors.toUnmodifiableSet; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.params.provider.Arguments.arguments; @@ -297,6 +303,38 @@ void find__whenApiKeyIsPresent__thenReturnProperResponse(String apiKey, boolean } } + @ParameterizedTest + @MethodSource("credentials") + void deleteExperimentsById_whenApiKeyIsPresent__thenReturnProperResponse(String apiKey, boolean success) { + var workspaceName = UUID.randomUUID().toString(); + + mockTargetWorkspace(okApikey, workspaceName, WORKSPACE_ID); + + var experiments = PodamFactoryUtils.manufacturePojoList(podamFactory, Experiment.class); + + experiments.forEach(experiment -> createAndAssert(experiment, okApikey, workspaceName)); + + Set ids = experiments.stream().map(Experiment::id).collect(toSet()); + + var deleteRequest = new ExperimentsDelete(ids); + + try (var actualResponse = client.target(getExperimentsPath()) + .path("delete") + .request() + .header(HttpHeaders.AUTHORIZATION, apiKey) + .header(WORKSPACE_HEADER, workspaceName) + .post(Entity.json(deleteRequest))) { + + if (success) { + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(204); + assertThat(actualResponse.hasEntity()).isFalse(); + } else { + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(401); + assertThat(actualResponse.readEntity(ErrorMessage.class)).isEqualTo(UNAUTHORIZED_RESPONSE); + } + } + } + @ParameterizedTest @MethodSource("credentials") void deleteExperimentItems__whenApiKeyIsPresent__thenReturnProperResponse(String apiKey, boolean success) { @@ -310,7 +348,7 @@ void deleteExperimentItems__whenApiKeyIsPresent__thenReturnProperResponse(String createRequest.experimentItems().forEach(item -> getAndAssert(item, workspaceName, okApikey)); - var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(Collectors.toSet()); + var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(toSet()); var deleteRequest = ExperimentItemsDelete.builder().ids(ids).build(); try (var actualResponse = client.target(getExperimentItemsPath()) @@ -505,6 +543,38 @@ void find__whenSessionTokenIsPresent__thenReturnProperResponse( } } + @ParameterizedTest + @MethodSource("credentials") + void deleteExperimentsById_whenSessionTokenIsPresent__thenReturnProperResponse( + String sessionToken, boolean success, String workspaceName) { + + mockTargetWorkspace(API_KEY, workspaceName, WORKSPACE_ID); + + var experiments = PodamFactoryUtils.manufacturePojoList(podamFactory, Experiment.class); + + experiments.forEach(experiment -> createAndAssert(experiment, API_KEY, workspaceName)); + + Set ids = experiments.stream().map(Experiment::id).collect(toSet()); + + var deleteRequest = new ExperimentsDelete(ids); + + try (var actualResponse = client.target(getExperimentsPath()) + .path("delete") + .request() + .cookie(SESSION_COOKIE, sessionToken) + .header(WORKSPACE_HEADER, workspaceName) + .post(Entity.json(deleteRequest))) { + + if (success) { + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(204); + assertThat(actualResponse.hasEntity()).isFalse(); + } else { + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(401); + assertThat(actualResponse.readEntity(ErrorMessage.class)).isEqualTo(UNAUTHORIZED_RESPONSE); + } + } + } + @ParameterizedTest @MethodSource("credentials") void deleteExperimentItems__whenSessionTokenIsPresent__thenReturnProperResponse( @@ -516,7 +586,7 @@ void deleteExperimentItems__whenSessionTokenIsPresent__thenReturnProperResponse( createAndAssert(createRequest, API_KEY, workspaceName); createRequest.experimentItems().forEach(item -> getAndAssert(item, workspaceName, API_KEY)); - var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(Collectors.toSet()); + var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(toSet()); var deleteRequest = ExperimentItemsDelete.builder().ids(ids).build(); try (var actualResponse = client.target(getExperimentItemsPath()) @@ -591,7 +661,6 @@ void getExperimentItemById__whenSessionTokenIsPresent__thenReturnProperResponse( } } } - } @Nested @@ -807,7 +876,7 @@ void findAllAndCalculateFeedbackAvg() { .of(scoreForTrace1.stream(), scoreForTrace2.stream(), scoreForTrace3.stream(), scoreForTrace4.stream(), scoreForTrace5.stream()) .flatMap(Function.identity()) - .collect(Collectors.groupingBy(FeedbackScoreBatchItem::id)); + .collect(groupingBy(FeedbackScoreBatchItem::id)); // When storing the scores in batch, adding some more unrelated random ones var feedbackScoreBatch = podamFactory.manufacturePojo(FeedbackScoreBatch.class); @@ -929,7 +998,7 @@ void findAllAndTraceDeleted() { .of(scoreForTrace1.stream(), scoreForTrace2.stream(), scoreForTrace3.stream(), scoreForTrace4.stream(), scoreForTrace5.stream(), scoreForTrace6.stream()) .flatMap(Function.identity()) - .collect(Collectors.groupingBy(FeedbackScoreBatchItem::id)); + .collect(groupingBy(FeedbackScoreBatchItem::id)); // When storing the scores in batch, adding some more unrelated random ones var feedbackScoreBatch = podamFactory.manufacturePojo(FeedbackScoreBatch.class); @@ -1023,15 +1092,15 @@ private void deleteTrace(UUID id, String apiKey, String workspaceName) { .filter(item -> item.experimentId().equals(experiment.id())) .map(ExperimentItem::feedbackScores) .flatMap(Collection::stream) - .collect(Collectors.groupingBy( + .collect(groupingBy( FeedbackScore::name, - Collectors.mapping(FeedbackScore::value, Collectors.toList()))) + mapping(FeedbackScore::value, toList()))) .entrySet() .stream() .map(e -> Map.entry(e.getKey(), avgFromList(e.getValue()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)))) .filter(entry -> !entry.getValue().isEmpty()) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); } private void findAndAssert( @@ -1150,7 +1219,7 @@ void createAndGet() { var traceIdToScoresMap = Stream .concat(Stream.concat(scoreForTrace1.stream(), scoreForTrace2.stream()), scoreForTrace3.stream()) - .collect(Collectors.groupingBy(FeedbackScoreBatchItem::id)); + .collect(groupingBy(FeedbackScoreBatchItem::id)); // When storing the scores in batch, adding some more unrelated random ones var feedbackScoreBatch = podamFactory.manufacturePojo(FeedbackScoreBatch.class); @@ -1217,7 +1286,7 @@ void createAndGetFeedbackAvg() { var traceIdToScoresMap = Stream .concat(Stream.concat(scoreForTrace1.stream(), scoreForTrace2.stream()), scoreForTrace3.stream()) - .collect(Collectors.groupingBy(FeedbackScoreBatchItem::id)); + .collect(groupingBy(FeedbackScoreBatchItem::id)); // When storing the scores in batch, adding some more unrelated random ones var feedbackScoreBatch = podamFactory.manufacturePojo(FeedbackScoreBatch.class); @@ -1380,7 +1449,7 @@ void createAndGetWithDeletedTrace() { .of(scoreForTrace1.stream(), scoreForTrace2.stream(), scoreForTrace3.stream(), scoreForTrace4.stream(), scoreForTrace5.stream(), scoreForTrace6.stream()) .flatMap(Function.identity()) - .collect(Collectors.groupingBy(FeedbackScoreBatchItem::id)); + .collect(groupingBy(FeedbackScoreBatchItem::id)); // When storing the scores in batch, adding some more unrelated random ones var feedbackScoreBatch = podamFactory.manufacturePojo(FeedbackScoreBatch.class); @@ -1416,7 +1485,7 @@ void createAndGetWithDeletedTrace() { traceIdToScoresMap.entrySet() .stream() .filter(e -> !e.getKey().equals(trace6.id())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); Experiment experiment = getAndAssert(expectedExperiment.id(), expectedExperiment, workspaceName, apiKey); @@ -1439,7 +1508,7 @@ private ExperimentItemsBatch addRandomExperiments(List experimen .experimentItems(Stream.concat( experimentItemsBatch.experimentItems().stream(), experimentItems.stream()) - .collect(Collectors.toUnmodifiableSet())) + .collect(toUnmodifiableSet())) .build(); return experimentItemsBatch; } @@ -1449,7 +1518,7 @@ private Map getScoresMap(Experiment experiment) { if (feedbackScores != null) { return feedbackScores .stream() - .collect(Collectors.toMap(FeedbackScoreAverage::name, FeedbackScoreAverage::value)); + .collect(toMap(FeedbackScoreAverage::name, FeedbackScoreAverage::value)); } return null; } @@ -1459,13 +1528,13 @@ private Map getExpectedScores(Map Map.entry(e.getKey(), avgFromList(e.getValue()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); } private BigDecimal avgFromList(List values) { @@ -1626,6 +1695,112 @@ void getById() { } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class DeleteExperimentItems { + + @Test + @DisplayName("Success") + void delete() { + var createRequest = podamFactory.manufacturePojo(ExperimentItemsBatch.class).toBuilder() + .build(); + createAndAssert(createRequest, API_KEY, TEST_WORKSPACE); + createRequest.experimentItems().forEach(item -> getAndAssert(item, TEST_WORKSPACE, API_KEY)); + + var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(toSet()); + var deleteRequest = ExperimentItemsDelete.builder().ids(ids).build(); + try (var actualResponse = client.target(getExperimentItemsPath()) + .path("delete") + .request() + .header(HttpHeaders.AUTHORIZATION, API_KEY) + .header(WORKSPACE_HEADER, TEST_WORKSPACE) + .post(Entity.json(deleteRequest))) { + + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(204); + assertThat(actualResponse.hasEntity()).isFalse(); + } + + ids.forEach(id -> getAndAssertNotFound(id, API_KEY, TEST_WORKSPACE)); + } + + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class DeleteExperiments { + + private void deleteExperimentAndAssert(Set ids, String apiKey, String workspaceName) { + try (var actualResponse = client.target(getExperimentsPath()) + .path("delete") + .request() + .header(HttpHeaders.AUTHORIZATION, apiKey) + .header(WORKSPACE_HEADER, workspaceName) + .post(Entity.json(new ExperimentsDelete(ids)))) { + + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(204); + } + + ids.parallelStream().forEach(id -> getExperimentAndAssertNotFound(id, apiKey, workspaceName)); + } + + private void getExperimentAndAssertNotFound(UUID id, String apiKey, String workspaceName) { + try (var actualResponse = client.target(getExperimentsPath()) + .path(id.toString()) + .request() + .header(HttpHeaders.AUTHORIZATION, apiKey) + .header(WORKSPACE_HEADER, workspaceName) + .get()) { + + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(404); + } + } + + @Test + void deleteExperimentsById__whenExperimentDoesNotExist__thenReturnNoContent() { + var experiment = podamFactory.manufacturePojo(Experiment.class); + + getExperimentAndAssertNotFound(experiment.id(), API_KEY, TEST_WORKSPACE); + + deleteExperimentAndAssert(Set.of(experiment.id()), API_KEY, TEST_WORKSPACE); + } + + @Test + void deleteExperimentsById__whenExperimentHasItems__thenReturnNoContent() { + var experiment = podamFactory.manufacturePojo(Experiment.class); + + createAndAssert(experiment, API_KEY, TEST_WORKSPACE); + + getAndAssert(experiment.id(), experiment, TEST_WORKSPACE, API_KEY); + + var experimentItems = PodamFactoryUtils.manufacturePojoList(podamFactory, ExperimentItem.class).stream() + .map(experimentItem -> experimentItem.toBuilder().experimentId(experiment.id()).build()) + .collect(toUnmodifiableSet()); + + var createRequest = ExperimentItemsBatch.builder().experimentItems(experimentItems).build(); + + createAndAssert(createRequest, API_KEY, TEST_WORKSPACE); + + deleteExperimentAndAssert(Set.of(experiment.id()), API_KEY, TEST_WORKSPACE); + + experimentItems + .parallelStream() + .forEach(experimentItem -> getAndAssertNotFound(experimentItem.id(), API_KEY, TEST_WORKSPACE)); + } + + @Test + void deleteExperimentsById__whenDeletingMultipleExperiments__thenReturnNoContent() { + var experiments = PodamFactoryUtils.manufacturePojoList(podamFactory, Experiment.class); + + experiments.parallelStream().forEach(experiment -> createAndAssert(experiment, API_KEY, TEST_WORKSPACE)); + experiments.parallelStream() + .forEach(experiment -> getAndAssert(experiment.id(), experiment, TEST_WORKSPACE, API_KEY)); + + Set ids = experiments.stream().map(Experiment::id).collect(toSet()); + + deleteExperimentAndAssert(ids, API_KEY, TEST_WORKSPACE); + } + } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) class StreamExperimentItems { @@ -1650,19 +1825,19 @@ void streamByExperimentName() { var experimentItems1 = PodamFactoryUtils.manufacturePojoList(podamFactory, ExperimentItem.class).stream() .map(experimentItem -> experimentItem.toBuilder().experimentId(experiment1.id()).build()) - .collect(Collectors.toUnmodifiableSet()); + .collect(toUnmodifiableSet()); var createRequest1 = ExperimentItemsBatch.builder().experimentItems(experimentItems1).build(); createAndAssert(createRequest1, apiKey, workspaceName); var experimentItems2 = PodamFactoryUtils.manufacturePojoList(podamFactory, ExperimentItem.class).stream() .map(experimentItem -> experimentItem.toBuilder().experimentId(experiment2.id()).build()) - .collect(Collectors.toUnmodifiableSet()); + .collect(toUnmodifiableSet()); var createRequest2 = ExperimentItemsBatch.builder().experimentItems(experimentItems2).build(); createAndAssert(createRequest2, apiKey, workspaceName); var experimentItems3 = PodamFactoryUtils.manufacturePojoList(podamFactory, ExperimentItem.class).stream() .map(experimentItem -> experimentItem.toBuilder().experimentId(experiment3.id()).build()) - .collect(Collectors.toUnmodifiableSet()); + .collect(toUnmodifiableSet()); var createRequest3 = ExperimentItemsBatch.builder().experimentItems(experimentItems3).build(); createAndAssert(createRequest3, apiKey, workspaceName); @@ -1874,34 +2049,6 @@ void insertInvalidId(ExperimentItem experimentItem, String expectedErrorMessage) } - @Nested - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - class DeleteExperimentsItems { - - @Test - void delete() { - var createRequest = podamFactory.manufacturePojo(ExperimentItemsBatch.class).toBuilder() - .build(); - createAndAssert(createRequest, API_KEY, TEST_WORKSPACE); - createRequest.experimentItems().forEach(item -> getAndAssert(item, TEST_WORKSPACE, API_KEY)); - - var ids = createRequest.experimentItems().stream().map(ExperimentItem::id).collect(Collectors.toSet()); - var deleteRequest = ExperimentItemsDelete.builder().ids(ids).build(); - try (var actualResponse = client.target(getExperimentItemsPath()) - .path("delete") - .request() - .header(HttpHeaders.AUTHORIZATION, API_KEY) - .header(WORKSPACE_HEADER, TEST_WORKSPACE) - .post(Entity.json(deleteRequest))) { - - assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(204); - assertThat(actualResponse.hasEntity()).isFalse(); - } - - ids.forEach(id -> getAndAssertNotFound(id, API_KEY, TEST_WORKSPACE)); - } - } - private void createAndAssert(ExperimentItemsBatch request, String apiKey, String workspaceName) { try (var actualResponse = client.target(getExperimentItemsPath()) .request()